17 tasks: marketing-pages with Pages collection + SiteSettings global + read use-cases + tRPC router; navigation with Header global + read use-case + tRPC router. Composes both into core-cms (now 4 collections, 2 globals) and core-api (now 4 namespaces). All Plan 2/3 lessons baked in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1875 lines
58 KiB
Markdown
1875 lines
58 KiB
Markdown
# 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<typeof pageSchema>;
|
|
export type PageStatus = z.infer<typeof pageStatusSchema>;
|
|
export type Hero = z.infer<typeof heroSchema>;
|
|
```
|
|
|
|
- [ ] **Step 4: Implement `packages/marketing-pages/src/entities/site-settings.ts`**
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
export const siteSettingsSchema = z.object({
|
|
siteName: z.string().min(1),
|
|
siteDescription: z.string().optional(),
|
|
});
|
|
|
|
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
|
|
```
|
|
|
|
- [ ] **Step 5: Implement `packages/marketing-pages/src/entities/errors.ts`**
|
|
|
|
```typescript
|
|
export class PageNotFoundError extends Error {
|
|
constructor(message = "Page not found", options?: ErrorOptions) {
|
|
super(message, options);
|
|
}
|
|
}
|
|
|
|
export class InputParseError extends Error {
|
|
constructor(message: string, options?: ErrorOptions) {
|
|
super(message, options);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run — expect 4 tests pass**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm vitest run src/entities`
|
|
Expected: PASS — 4 tests.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/entities
|
|
git commit -m "feat(marketing-pages): add Page + SiteSettings entities + errors"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.3: Repository interfaces (pages + site-settings)
|
|
|
|
- [ ] **Step 1: Create `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts`**
|
|
|
|
```typescript
|
|
import type { Page } from "../../entities/page";
|
|
|
|
export interface IPagesRepository {
|
|
getPageBySlug(slug: string): Promise<Page | undefined>;
|
|
getPages(options?: {
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Page[]>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts`**
|
|
|
|
```typescript
|
|
import type { SiteSettings } from "../../entities/site-settings";
|
|
|
|
export interface ISiteSettingsRepository {
|
|
getSiteSettings(): Promise<SiteSettings>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compiles**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/application
|
|
git commit -m "feat(marketing-pages): add IPagesRepository + ISiteSettingsRepository interfaces"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.4: Use-cases + tests (RED until DI lands)
|
|
|
|
- [ ] **Step 1: Write test `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { marketingPagesContainer } from "@/di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
|
|
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
|
|
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
|
|
import { getPageBySlugUseCase } from "./get-page-by-slug.use-case";
|
|
|
|
describe("getPageBySlugUseCase", () => {
|
|
let repo: MockPagesRepository;
|
|
|
|
beforeEach(() => {
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
|
|
}
|
|
repo = new MockPagesRepository();
|
|
marketingPagesContainer
|
|
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
|
|
.toConstantValue(repo);
|
|
});
|
|
|
|
it("returns the page when found", async () => {
|
|
const result = await getPageBySlugUseCase("about");
|
|
expect(result?.slug).toBe("about");
|
|
});
|
|
|
|
it("returns undefined when not found", async () => {
|
|
const result = await getPageBySlugUseCase("missing-page");
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts`**
|
|
|
|
```typescript
|
|
import type { Page } from "../../entities/page";
|
|
import { marketingPagesContainer } from "../../di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
|
|
import type { IPagesRepository } from "../repositories/pages-repository.interface";
|
|
|
|
export async function getPageBySlugUseCase(
|
|
slug: string,
|
|
): Promise<Page | undefined> {
|
|
const repo = marketingPagesContainer.get<IPagesRepository>(
|
|
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
|
);
|
|
return repo.getPageBySlug(slug);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit (test RED)**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts
|
|
git commit -m "feat(marketing-pages): add getPageBySlugUseCase (test red until DI lands)"
|
|
```
|
|
|
|
- [ ] **Step 4: Write test `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { marketingPagesContainer } from "@/di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
|
|
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
|
|
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
|
|
import { getSiteSettingsUseCase } from "./get-site-settings.use-case";
|
|
|
|
describe("getSiteSettingsUseCase", () => {
|
|
let repo: MockSiteSettingsRepository;
|
|
|
|
beforeEach(() => {
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
|
|
}
|
|
repo = new MockSiteSettingsRepository();
|
|
marketingPagesContainer
|
|
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
|
|
.toConstantValue(repo);
|
|
});
|
|
|
|
it("returns the seeded site settings", async () => {
|
|
const result = await getSiteSettingsUseCase();
|
|
expect(result.siteName).toBe("My App");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Implement `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts`**
|
|
|
|
```typescript
|
|
import type { SiteSettings } from "../../entities/site-settings";
|
|
import { marketingPagesContainer } from "../../di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
|
|
import type { ISiteSettingsRepository } from "../repositories/site-settings-repository.interface";
|
|
|
|
export async function getSiteSettingsUseCase(): Promise<SiteSettings> {
|
|
const repo = marketingPagesContainer.get<ISiteSettingsRepository>(
|
|
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
|
|
);
|
|
return repo.getSiteSettings();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Commit (test RED)**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts
|
|
git commit -m "feat(marketing-pages): add getSiteSettingsUseCase (test red until DI lands)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.5: Mock repositories (pages + site-settings)
|
|
|
|
- [ ] **Step 1: Create `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
|
|
import type { IPagesRepository } from "../../application/repositories/pages-repository.interface";
|
|
import type { Page } from "../../entities/page";
|
|
|
|
const SEED_DATE = new Date("2026-01-01T00:00:00.000Z");
|
|
|
|
@injectable()
|
|
export class MockPagesRepository implements IPagesRepository {
|
|
private _pages: Page[] = [
|
|
{
|
|
id: "p1",
|
|
title: "About",
|
|
slug: "about",
|
|
hero: { heading: "About us" },
|
|
layout: [],
|
|
status: "published",
|
|
publishedAt: SEED_DATE,
|
|
seo: { title: "About — My App" },
|
|
createdAt: SEED_DATE,
|
|
updatedAt: SEED_DATE,
|
|
},
|
|
];
|
|
|
|
async getPageBySlug(slug: string): Promise<Page | undefined> {
|
|
return this._pages.find((p) => p.slug === slug);
|
|
}
|
|
|
|
async getPages(options?: {
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Page[]> {
|
|
let result = [...this._pages];
|
|
if (options?.status) {
|
|
result = result.filter((p) => p.status === options.status);
|
|
}
|
|
const offset = options?.offset ?? 0;
|
|
const limit = options?.limit ?? 50;
|
|
return result.slice(offset, offset + limit);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
|
|
import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface";
|
|
import type { SiteSettings } from "../../entities/site-settings";
|
|
|
|
@injectable()
|
|
export class MockSiteSettingsRepository implements ISiteSettingsRepository {
|
|
async getSiteSettings(): Promise<SiteSettings> {
|
|
return {
|
|
siteName: "My App",
|
|
siteDescription: "A vertical-feature monorepo template",
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compiles**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/infrastructure/repositories
|
|
git commit -m "feat(marketing-pages): add Mock pages + site-settings repositories"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.6: Per-feature DI container + tests
|
|
|
|
- [ ] **Step 1: Write `packages/marketing-pages/src/di/container.test.ts`**
|
|
|
|
```typescript
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { marketingPagesContainer } from "./container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
|
|
import { MarketingPagesModule } from "./module";
|
|
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
|
|
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
|
|
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
|
|
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
|
|
|
|
describe("marketingPagesContainer", () => {
|
|
beforeEach(() => {
|
|
marketingPagesContainer.unbindAll();
|
|
marketingPagesContainer.load(MarketingPagesModule);
|
|
});
|
|
|
|
afterEach(() => {
|
|
marketingPagesContainer.unbindAll();
|
|
});
|
|
|
|
it("resolves IPagesRepository to MockPagesRepository", () => {
|
|
const repo = marketingPagesContainer.get<IPagesRepository>(
|
|
MARKETING_PAGES_SYMBOLS.IPagesRepository,
|
|
);
|
|
expect(repo).toBeInstanceOf(MockPagesRepository);
|
|
});
|
|
|
|
it("resolves ISiteSettingsRepository to MockSiteSettingsRepository", () => {
|
|
const repo = marketingPagesContainer.get<ISiteSettingsRepository>(
|
|
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
|
|
);
|
|
expect(repo).toBeInstanceOf(MockSiteSettingsRepository);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/marketing-pages/src/di/symbols.ts`**
|
|
|
|
```typescript
|
|
export const MARKETING_PAGES_SYMBOLS = {
|
|
IPagesRepository: Symbol.for("marketing-pages:IPagesRepository"),
|
|
ISiteSettingsRepository: Symbol.for("marketing-pages:ISiteSettingsRepository"),
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `packages/marketing-pages/src/di/module.ts`**
|
|
|
|
```typescript
|
|
import { ContainerModule, type interfaces } from "inversify";
|
|
|
|
import type { IPagesRepository } from "../application/repositories/pages-repository.interface";
|
|
import type { ISiteSettingsRepository } from "../application/repositories/site-settings-repository.interface";
|
|
import { MockPagesRepository } from "../infrastructure/repositories/mock-pages.repository";
|
|
import { MockSiteSettingsRepository } from "../infrastructure/repositories/mock-site-settings.repository";
|
|
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
|
|
|
|
export const MarketingPagesModule = new ContainerModule(
|
|
(bind: interfaces.Bind) => {
|
|
bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository).to(
|
|
MockPagesRepository,
|
|
);
|
|
bind<ISiteSettingsRepository>(
|
|
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
|
|
).to(MockSiteSettingsRepository);
|
|
},
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 4: Implement `packages/marketing-pages/src/di/container.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { Container } from "inversify";
|
|
import { MarketingPagesModule } from "./module";
|
|
|
|
export const marketingPagesContainer = new Container({
|
|
defaultScope: "Singleton",
|
|
});
|
|
marketingPagesContainer.load(MarketingPagesModule);
|
|
```
|
|
|
|
- [ ] **Step 5: Run container test — expect 2 PASS**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm vitest run src/di/container.test.ts`
|
|
Expected: PASS — 2 tests.
|
|
|
|
- [ ] **Step 6: Run previously-RED use-case tests**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm vitest run src/application/use-cases`
|
|
Expected: PASS — 3 tests (page-by-slug 2 + site-settings 1).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/di
|
|
git commit -m "feat(marketing-pages): add per-feature InversifyJS container"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.7: Payload-backed repositories
|
|
|
|
Both repositories take `SanitizedConfig` via constructor (per Plan 2 lesson #4).
|
|
|
|
- [ ] **Step 1: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
import { getPayload } from "payload";
|
|
import type { SanitizedConfig } from "payload";
|
|
|
|
import type { IPagesRepository } from "../../application/repositories/pages-repository.interface";
|
|
import type { Page } from "../../entities/page";
|
|
|
|
type PayloadPageDoc = {
|
|
id: string | number;
|
|
title?: string | null;
|
|
slug?: string | null;
|
|
hero?:
|
|
| {
|
|
heading?: string | null;
|
|
subheading?: string | null;
|
|
image?: string | number | { id: string | number } | null;
|
|
}
|
|
| null;
|
|
layout?: unknown[] | null;
|
|
status?: string | null;
|
|
publishedAt?: string | null;
|
|
seo?: { title?: string | null; description?: string | null } | null;
|
|
createdAt?: string | null;
|
|
updatedAt?: string | null;
|
|
};
|
|
|
|
function mapDoc(doc: PayloadPageDoc): Page {
|
|
const imageId =
|
|
doc.hero && typeof doc.hero.image === "object" && doc.hero.image !== null
|
|
? String(doc.hero.image.id)
|
|
: doc.hero?.image != null
|
|
? String(doc.hero.image)
|
|
: undefined;
|
|
|
|
return {
|
|
id: String(doc.id),
|
|
title: doc.title ?? "",
|
|
slug: doc.slug ?? "",
|
|
hero: {
|
|
heading: doc.hero?.heading ?? "",
|
|
subheading: doc.hero?.subheading ?? undefined,
|
|
imageId,
|
|
},
|
|
layout: doc.layout ?? [],
|
|
status: doc.status === "published" ? "published" : "draft",
|
|
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : null,
|
|
seo: {
|
|
title: doc.seo?.title ?? "",
|
|
description: doc.seo?.description ?? undefined,
|
|
},
|
|
createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
|
|
updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
|
|
};
|
|
}
|
|
|
|
@injectable()
|
|
export class PayloadPagesRepository implements IPagesRepository {
|
|
private config: SanitizedConfig;
|
|
|
|
constructor(config: SanitizedConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
async getPageBySlug(slug: string): Promise<Page | undefined> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const result = await payload.find({
|
|
collection: "pages",
|
|
where: { slug: { equals: slug } },
|
|
limit: 1,
|
|
overrideAccess: false,
|
|
});
|
|
const doc = result.docs[0] as PayloadPageDoc | undefined;
|
|
return doc ? mapDoc(doc) : undefined;
|
|
}
|
|
|
|
async getPages(options?: {
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Page[]> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const where: Record<string, { equals: string }> = {};
|
|
if (options?.status) where.status = { equals: options.status };
|
|
const result = await payload.find({
|
|
collection: "pages",
|
|
where: where as never,
|
|
limit: options?.limit ?? 50,
|
|
page: options?.offset
|
|
? Math.floor(options.offset / (options.limit ?? 50)) + 1
|
|
: 1,
|
|
overrideAccess: false,
|
|
});
|
|
return result.docs.map((d) => mapDoc(d as PayloadPageDoc));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
import { getPayload } from "payload";
|
|
import type { SanitizedConfig } from "payload";
|
|
|
|
import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface";
|
|
import type { SiteSettings } from "../../entities/site-settings";
|
|
|
|
type PayloadSiteSettings = {
|
|
siteName?: string | null;
|
|
siteDescription?: string | null;
|
|
};
|
|
|
|
@injectable()
|
|
export class PayloadSiteSettingsRepository implements ISiteSettingsRepository {
|
|
private config: SanitizedConfig;
|
|
|
|
constructor(config: SanitizedConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
async getSiteSettings(): Promise<SiteSettings> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const doc = (await payload.findGlobal({
|
|
slug: "site-settings",
|
|
overrideAccess: false,
|
|
})) as PayloadSiteSettings;
|
|
return {
|
|
siteName: doc.siteName ?? "My App",
|
|
siteDescription: doc.siteDescription ?? undefined,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compiles**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/infrastructure/repositories
|
|
git commit -m "feat(marketing-pages): add Payload-backed pages + site-settings repos (constructor-injected config)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.8: Pages controller + tests
|
|
|
|
- [ ] **Step 1: Write test `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { marketingPagesContainer } from "@/di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
|
|
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
|
|
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
|
|
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
|
|
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
|
|
import { InputParseError } from "@/entities/errors";
|
|
import {
|
|
getPageBySlugController,
|
|
getSiteSettingsController,
|
|
} from "./pages.controller";
|
|
|
|
describe("pages controller", () => {
|
|
beforeEach(() => {
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
|
|
}
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
|
|
}
|
|
marketingPagesContainer
|
|
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
|
|
.toConstantValue(new MockPagesRepository());
|
|
marketingPagesContainer
|
|
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
|
|
.toConstantValue(new MockSiteSettingsRepository());
|
|
});
|
|
|
|
describe("getPageBySlugController", () => {
|
|
it("returns the page when found", async () => {
|
|
const result = await getPageBySlugController({ slug: "about" });
|
|
expect(result?.slug).toBe("about");
|
|
});
|
|
|
|
it("throws InputParseError on missing slug", async () => {
|
|
await expect(
|
|
getPageBySlugController({} as { slug: string }),
|
|
).rejects.toBeInstanceOf(InputParseError);
|
|
});
|
|
});
|
|
|
|
describe("getSiteSettingsController", () => {
|
|
it("returns site settings", async () => {
|
|
const result = await getSiteSettingsController();
|
|
expect(result.siteName).toBe("My App");
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts`**
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
import { InputParseError } from "../../entities/errors";
|
|
import type { Page } from "../../entities/page";
|
|
import type { SiteSettings } from "../../entities/site-settings";
|
|
import { getPageBySlugUseCase } from "../../application/use-cases/get-page-by-slug.use-case";
|
|
import { getSiteSettingsUseCase } from "../../application/use-cases/get-site-settings.use-case";
|
|
|
|
const getBySlugInputSchema = z.object({
|
|
slug: z.string().min(1),
|
|
});
|
|
|
|
export async function getPageBySlugController(input: {
|
|
slug: string;
|
|
}): Promise<Page | undefined> {
|
|
const parsed = getBySlugInputSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
throw new InputParseError("Invalid get-page-by-slug input", {
|
|
cause: parsed.error,
|
|
});
|
|
}
|
|
return getPageBySlugUseCase(parsed.data.slug);
|
|
}
|
|
|
|
export async function getSiteSettingsController(): Promise<SiteSettings> {
|
|
return getSiteSettingsUseCase();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run — expect 3 PASS**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm vitest run src/interface-adapters`
|
|
Expected: PASS — 3 tests.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/interface-adapters
|
|
git commit -m "feat(marketing-pages): add pages controller (getBySlug + getSiteSettings)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.9: Pages collection + SiteSettings global
|
|
|
|
- [ ] **Step 1: Create `packages/marketing-pages/src/integrations/cms/collections/pages.ts`**
|
|
|
|
```typescript
|
|
import type { CollectionConfig } from "payload";
|
|
import {
|
|
cta,
|
|
seoFields,
|
|
slugifyIfMissing,
|
|
} from "@repo/core-shared/payload";
|
|
|
|
export const pages: CollectionConfig = {
|
|
slug: "pages",
|
|
admin: {
|
|
useAsTitle: "title",
|
|
defaultColumns: ["title", "status", "updatedAt"],
|
|
},
|
|
hooks: {
|
|
beforeChange: [slugifyIfMissing],
|
|
},
|
|
versions: {
|
|
drafts: true,
|
|
},
|
|
fields: [
|
|
{
|
|
name: "title",
|
|
type: "text",
|
|
required: true,
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
name: "slug",
|
|
type: "text",
|
|
unique: true,
|
|
admin: {
|
|
position: "sidebar",
|
|
description: "Auto-generated from title if left empty",
|
|
},
|
|
},
|
|
{
|
|
name: "hero",
|
|
type: "group",
|
|
fields: [
|
|
{ name: "heading", type: "text", required: true },
|
|
{ name: "subheading", type: "textarea" },
|
|
{
|
|
name: "image",
|
|
type: "upload",
|
|
relationTo: "media",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "layout",
|
|
type: "blocks",
|
|
blocks: [cta],
|
|
},
|
|
{
|
|
name: "status",
|
|
type: "select",
|
|
options: [
|
|
{ label: "Draft", value: "draft" },
|
|
{ label: "Published", value: "published" },
|
|
],
|
|
defaultValue: "draft",
|
|
required: true,
|
|
admin: { position: "sidebar" },
|
|
},
|
|
{
|
|
name: "publishedAt",
|
|
type: "date",
|
|
admin: {
|
|
position: "sidebar",
|
|
date: { pickerAppearance: "dayAndTime" },
|
|
},
|
|
},
|
|
seoFields,
|
|
],
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts`**
|
|
|
|
```typescript
|
|
import type { GlobalConfig } from "payload";
|
|
|
|
export const siteSettings: GlobalConfig = {
|
|
slug: "site-settings",
|
|
admin: {
|
|
group: "Settings",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "siteName",
|
|
type: "text",
|
|
required: true,
|
|
defaultValue: "My App",
|
|
},
|
|
{
|
|
name: "siteDescription",
|
|
type: "textarea",
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: Create `packages/marketing-pages/src/integrations/cms/index.ts`**
|
|
|
|
```typescript
|
|
export { pages } from "./collections/pages";
|
|
export { siteSettings } from "./globals/site-settings";
|
|
```
|
|
|
|
- [ ] **Step 4: Verify compiles**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/integrations/cms
|
|
git commit -m "feat(marketing-pages): add pages collection + siteSettings global"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.10: tRPC router + tests
|
|
|
|
- [ ] **Step 1: Write test `packages/marketing-pages/src/integrations/api/router.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { marketingPagesContainer } from "@/di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
|
|
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
|
|
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
|
|
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
|
|
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
|
|
import { marketingPagesRouter } from "./router";
|
|
|
|
describe("marketingPagesRouter", () => {
|
|
beforeEach(() => {
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
|
|
}
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
|
|
}
|
|
marketingPagesContainer
|
|
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
|
|
.toConstantValue(new MockPagesRepository());
|
|
marketingPagesContainer
|
|
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
|
|
.toConstantValue(new MockSiteSettingsRepository());
|
|
});
|
|
|
|
it("exposes pageBySlug + siteSettings procedures", () => {
|
|
const names = Object.keys(marketingPagesRouter._def.procedures);
|
|
expect(names).toContain("pageBySlug");
|
|
expect(names).toContain("siteSettings");
|
|
});
|
|
|
|
it("pageBySlug returns the seeded About page", async () => {
|
|
const caller = marketingPagesRouter.createCaller({});
|
|
const result = await caller.pageBySlug({ slug: "about" });
|
|
expect(result?.title).toBe("About");
|
|
});
|
|
|
|
it("siteSettings returns site name", async () => {
|
|
const caller = marketingPagesRouter.createCaller({});
|
|
const result = await caller.siteSettings();
|
|
expect(result.siteName).toBe("My App");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/marketing-pages/src/integrations/api/router.ts`**
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
|
import {
|
|
getPageBySlugController,
|
|
getSiteSettingsController,
|
|
} from "../../interface-adapters/controllers/pages.controller";
|
|
|
|
export const marketingPagesRouter = router({
|
|
pageBySlug: publicProcedure
|
|
.input(z.object({ slug: z.string().min(1) }))
|
|
.query(({ input }) => getPageBySlugController(input)),
|
|
|
|
siteSettings: publicProcedure.query(() => getSiteSettingsController()),
|
|
});
|
|
|
|
export type MarketingPagesRouter = typeof marketingPagesRouter;
|
|
```
|
|
|
|
- [ ] **Step 3: Run — expect 3 PASS**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm vitest run src/integrations/api/router.test.ts`
|
|
Expected: PASS — 3 tests.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/integrations/api
|
|
git commit -m "feat(marketing-pages): add tRPC router with pageBySlug + siteSettings"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.11: ui/query + barrel + feature test
|
|
|
|
- [ ] **Step 1: Create `packages/marketing-pages/src/ui/query.ts`**
|
|
|
|
```typescript
|
|
type TrpcClient = {
|
|
marketingPages: {
|
|
pageBySlug: { queryOptions: (input: { slug: string }) => unknown };
|
|
siteSettings: { queryOptions: () => unknown };
|
|
};
|
|
};
|
|
|
|
export function pageBySlugQuery(client: TrpcClient, slug: string) {
|
|
return client.marketingPages.pageBySlug.queryOptions({ slug });
|
|
}
|
|
|
|
export function siteSettingsQuery(client: TrpcClient) {
|
|
return client.marketingPages.siteSettings.queryOptions();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `packages/marketing-pages/src/index.ts`**
|
|
|
|
```typescript
|
|
export type { Page, PageStatus, Hero } from "./entities/page";
|
|
export type { SiteSettings } from "./entities/site-settings";
|
|
export { PageNotFoundError, InputParseError } from "./entities/errors";
|
|
export { pageBySlugQuery, siteSettingsQuery } from "./ui/query";
|
|
```
|
|
|
|
- [ ] **Step 3: Create feature test `packages/marketing-pages/tests/page-by-slug.feature.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { marketingPagesContainer } from "../src/di/container";
|
|
import { MARKETING_PAGES_SYMBOLS } from "../src/di/symbols";
|
|
import { MockPagesRepository } from "../src/infrastructure/repositories/mock-pages.repository";
|
|
import { MockSiteSettingsRepository } from "../src/infrastructure/repositories/mock-site-settings.repository";
|
|
import type { IPagesRepository } from "../src/application/repositories/pages-repository.interface";
|
|
import type { ISiteSettingsRepository } from "../src/application/repositories/site-settings-repository.interface";
|
|
import { marketingPagesRouter } from "../src/integrations/api/router";
|
|
|
|
describe("marketing-pages feature: page-by-slug end-to-end", () => {
|
|
beforeEach(() => {
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
|
|
}
|
|
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
|
|
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
|
|
}
|
|
marketingPagesContainer
|
|
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
|
|
.toConstantValue(new MockPagesRepository());
|
|
marketingPagesContainer
|
|
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
|
|
.toConstantValue(new MockSiteSettingsRepository());
|
|
});
|
|
|
|
it("fetches a page by slug via tRPC and returns the domain entity", async () => {
|
|
const caller = marketingPagesRouter.createCaller({});
|
|
const page = await caller.pageBySlug({ slug: "about" });
|
|
expect(page?.title).toBe("About");
|
|
expect(page?.hero.heading).toBe("About us");
|
|
expect(page?.status).toBe("published");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run all marketing-pages tests**
|
|
|
|
Run: `cd packages/marketing-pages && pnpm test`
|
|
Expected: PASS — entities (4) + di (2) + use-cases (3) + controller (3) + router (3) + feature (1) = 16 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/marketing-pages/src/ui packages/marketing-pages/src/index.ts packages/marketing-pages/tests
|
|
git commit -m "feat(marketing-pages): add ui/query + barrel + feature test"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase B: Navigation feature
|
|
|
|
### Task 4.12: Scaffold @repo/navigation package
|
|
|
|
- [ ] **Step 1: Create `packages/navigation/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "@repo/navigation",
|
|
"private": true,
|
|
"version": "0.0.0",
|
|
"type": "module",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./cms": "./src/integrations/cms/index.ts",
|
|
"./api": "./src/integrations/api/router.ts"
|
|
},
|
|
"scripts": {
|
|
"build": "tsc --noEmit",
|
|
"lint": "eslint .",
|
|
"test": "vitest run --passWithNoTests",
|
|
"typecheck": "tsc --noEmit"
|
|
},
|
|
"dependencies": {
|
|
"@repo/core-shared": "workspace:*",
|
|
"@trpc/server": "^11.0.0",
|
|
"inversify": "^6.2.0",
|
|
"payload": "^3.14.0",
|
|
"reflect-metadata": "^0.2.2",
|
|
"zod": "^3.24.0"
|
|
},
|
|
"devDependencies": {
|
|
"@repo/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<typeof headerSchema>;
|
|
export type HeaderItem = z.infer<typeof headerItemSchema>;
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/navigation/src/application/repositories/header-repository.interface.ts`**
|
|
|
|
```typescript
|
|
import type { Header } from "../../entities/header";
|
|
|
|
export interface IHeaderRepository {
|
|
getHeader(): Promise<Header>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write test `packages/navigation/src/application/use-cases/get-header.use-case.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { navigationContainer } from "@/di/container";
|
|
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
|
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
|
|
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
|
|
import { getHeaderUseCase } from "./get-header.use-case";
|
|
|
|
describe("getHeaderUseCase", () => {
|
|
beforeEach(() => {
|
|
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
|
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
|
}
|
|
navigationContainer
|
|
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
|
.toConstantValue(new MockHeaderRepository());
|
|
});
|
|
|
|
it("returns the seeded header items", async () => {
|
|
const result = await getHeaderUseCase();
|
|
expect(result.items.length).toBeGreaterThan(0);
|
|
expect(result.items[0]?.label).toBe("Home");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Implement `packages/navigation/src/application/use-cases/get-header.use-case.ts`**
|
|
|
|
```typescript
|
|
import type { Header } from "../../entities/header";
|
|
import { navigationContainer } from "../../di/container";
|
|
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
|
|
import type { IHeaderRepository } from "../repositories/header-repository.interface";
|
|
|
|
export async function getHeaderUseCase(): Promise<Header> {
|
|
const repo = navigationContainer.get<IHeaderRepository>(
|
|
NAVIGATION_SYMBOLS.IHeaderRepository,
|
|
);
|
|
return repo.getHeader();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Implement `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
|
|
import type { IHeaderRepository } from "../../application/repositories/header-repository.interface";
|
|
import type { Header } from "../../entities/header";
|
|
|
|
@injectable()
|
|
export class MockHeaderRepository implements IHeaderRepository {
|
|
async getHeader(): Promise<Header> {
|
|
return {
|
|
items: [
|
|
{ label: "Home", href: "/", external: false },
|
|
{ label: "Blog", href: "/blog", external: false },
|
|
{ label: "About", href: "/about", external: false },
|
|
],
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Implement `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
import { getPayload } from "payload";
|
|
import type { SanitizedConfig } from "payload";
|
|
|
|
import type { IHeaderRepository } from "../../application/repositories/header-repository.interface";
|
|
import type { Header, HeaderItem } from "../../entities/header";
|
|
|
|
type PayloadHeaderGlobal = {
|
|
logo?: string | number | { id: string | number } | null;
|
|
items?: Array<{
|
|
label?: string | null;
|
|
href?: string | null;
|
|
external?: boolean | null;
|
|
}> | null;
|
|
};
|
|
|
|
@injectable()
|
|
export class PayloadHeaderRepository implements IHeaderRepository {
|
|
private config: SanitizedConfig;
|
|
|
|
constructor(config: SanitizedConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
async getHeader(): Promise<Header> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const doc = (await payload.findGlobal({
|
|
slug: "header",
|
|
overrideAccess: false,
|
|
})) as PayloadHeaderGlobal;
|
|
|
|
const logoId =
|
|
typeof doc.logo === "object" && doc.logo !== null
|
|
? String(doc.logo.id)
|
|
: doc.logo != null
|
|
? String(doc.logo)
|
|
: undefined;
|
|
|
|
const items: HeaderItem[] = (doc.items ?? []).map((item) => ({
|
|
label: item.label ?? "",
|
|
href: item.href ?? "",
|
|
external: item.external ?? false,
|
|
}));
|
|
|
|
return { logoId, items };
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Implement `packages/navigation/src/di/symbols.ts`**
|
|
|
|
```typescript
|
|
export const NAVIGATION_SYMBOLS = {
|
|
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 8: Implement `packages/navigation/src/di/module.ts`**
|
|
|
|
```typescript
|
|
import { ContainerModule, type interfaces } from "inversify";
|
|
|
|
import type { IHeaderRepository } from "../application/repositories/header-repository.interface";
|
|
import { MockHeaderRepository } from "../infrastructure/repositories/mock-header.repository";
|
|
import { NAVIGATION_SYMBOLS } from "./symbols";
|
|
|
|
export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
|
|
bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
|
|
MockHeaderRepository,
|
|
);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 9: Implement `packages/navigation/src/di/container.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { Container } from "inversify";
|
|
import { NavigationModule } from "./module";
|
|
|
|
export const navigationContainer = new Container({ defaultScope: "Singleton" });
|
|
navigationContainer.load(NavigationModule);
|
|
```
|
|
|
|
- [ ] **Step 10: Write `packages/navigation/src/di/container.test.ts`**
|
|
|
|
```typescript
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { navigationContainer } from "./container";
|
|
import { NAVIGATION_SYMBOLS } from "./symbols";
|
|
import { NavigationModule } from "./module";
|
|
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
|
|
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
|
|
|
|
describe("navigationContainer", () => {
|
|
beforeEach(() => {
|
|
navigationContainer.unbindAll();
|
|
navigationContainer.load(NavigationModule);
|
|
});
|
|
|
|
afterEach(() => {
|
|
navigationContainer.unbindAll();
|
|
});
|
|
|
|
it("resolves IHeaderRepository to MockHeaderRepository", () => {
|
|
const repo = navigationContainer.get<IHeaderRepository>(
|
|
NAVIGATION_SYMBOLS.IHeaderRepository,
|
|
);
|
|
expect(repo).toBeInstanceOf(MockHeaderRepository);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 11: Run all tests**
|
|
|
|
Run: `cd packages/navigation && pnpm test`
|
|
Expected: PASS — 2 tests (use-case 1 + container 1).
|
|
|
|
- [ ] **Step 12: Commit**
|
|
|
|
```bash
|
|
git add packages/navigation/src
|
|
git commit -m "feat(navigation): add Header entity + use-case + mock/payload repos + DI container"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.14: Header controller + tRPC router + tests + CMS global + barrel
|
|
|
|
- [ ] **Step 1: Create `packages/navigation/src/interface-adapters/controllers/header.controller.ts`**
|
|
|
|
```typescript
|
|
import type { Header } from "../../entities/header";
|
|
import { getHeaderUseCase } from "../../application/use-cases/get-header.use-case";
|
|
|
|
export async function getHeaderController(): Promise<Header> {
|
|
return getHeaderUseCase();
|
|
}
|
|
```
|
|
|
|
> Note: No Zod input — `getHeader` takes no input. No InputParseError needed.
|
|
|
|
- [ ] **Step 2: Create `packages/navigation/src/integrations/cms/globals/header.ts`**
|
|
|
|
```typescript
|
|
import type { GlobalConfig } from "payload";
|
|
|
|
export const header: GlobalConfig = {
|
|
slug: "header",
|
|
admin: {
|
|
group: "Navigation",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "logo",
|
|
type: "upload",
|
|
relationTo: "media",
|
|
},
|
|
{
|
|
name: "items",
|
|
type: "array",
|
|
fields: [
|
|
{ name: "label", type: "text", required: true },
|
|
{ name: "href", type: "text", required: true },
|
|
{ name: "external", type: "checkbox", defaultValue: false },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: Create `packages/navigation/src/integrations/cms/index.ts`**
|
|
|
|
```typescript
|
|
export { header } from "./globals/header";
|
|
```
|
|
|
|
- [ ] **Step 4: Write test `packages/navigation/src/integrations/api/router.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { navigationContainer } from "@/di/container";
|
|
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
|
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
|
|
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
|
|
import { navigationRouter } from "./router";
|
|
|
|
describe("navigationRouter", () => {
|
|
beforeEach(() => {
|
|
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
|
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
|
}
|
|
navigationContainer
|
|
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
|
.toConstantValue(new MockHeaderRepository());
|
|
});
|
|
|
|
it("exposes header procedure", () => {
|
|
const names = Object.keys(navigationRouter._def.procedures);
|
|
expect(names).toContain("header");
|
|
});
|
|
|
|
it("header returns 3 items", async () => {
|
|
const caller = navigationRouter.createCaller({});
|
|
const result = await caller.header();
|
|
expect(result.items).toHaveLength(3);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Implement `packages/navigation/src/integrations/api/router.ts`**
|
|
|
|
```typescript
|
|
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
|
import { getHeaderController } from "../../interface-adapters/controllers/header.controller";
|
|
|
|
export const navigationRouter = router({
|
|
header: publicProcedure.query(() => getHeaderController()),
|
|
});
|
|
|
|
export type NavigationRouter = typeof navigationRouter;
|
|
```
|
|
|
|
- [ ] **Step 6: Create `packages/navigation/src/ui/query.ts`**
|
|
|
|
```typescript
|
|
type TrpcClient = {
|
|
navigation: {
|
|
header: { queryOptions: () => unknown };
|
|
};
|
|
};
|
|
|
|
export function headerQuery(client: TrpcClient) {
|
|
return client.navigation.header.queryOptions();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Replace `packages/navigation/src/index.ts`**
|
|
|
|
```typescript
|
|
export type { Header, HeaderItem } from "./entities/header";
|
|
export { headerQuery } from "./ui/query";
|
|
```
|
|
|
|
- [ ] **Step 8: Run all navigation tests**
|
|
|
|
Run: `cd packages/navigation && pnpm test`
|
|
Expected: PASS — 4 tests (use-case 1 + container 1 + router 2).
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add packages/navigation/src
|
|
git commit -m "feat(navigation): add controller + tRPC router + header global + barrel"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase C: Compose into core-cms + core-api
|
|
|
|
### Task 4.15: Wire pages + globals into core-cms
|
|
|
|
- [ ] **Step 1: Add `@repo/marketing-pages` and `@repo/navigation` to `packages/core-cms/package.json`**
|
|
|
|
Add to `dependencies`:
|
|
|
|
```json
|
|
"@repo/marketing-pages": "workspace:*",
|
|
"@repo/navigation": "workspace:*",
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`**
|
|
|
|
```typescript
|
|
import { buildConfig } from "payload";
|
|
import { postgresAdapter } from "@payloadcms/db-postgres";
|
|
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { users } from "@repo/auth/cms";
|
|
import { articles } from "@repo/blog/cms";
|
|
import { media } from "@repo/media/cms";
|
|
import { pages, siteSettings } from "@repo/marketing-pages/cms";
|
|
import { header } from "@repo/navigation/cms";
|
|
|
|
const filename = fileURLToPath(import.meta.url);
|
|
const dirname = path.dirname(filename);
|
|
|
|
export default buildConfig({
|
|
editor: lexicalEditor(),
|
|
collections: [users, articles, pages, media],
|
|
globals: [siteSettings, header],
|
|
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
|
db: postgresAdapter({
|
|
pool: {
|
|
connectionString:
|
|
process.env.DATABASE_URL ||
|
|
"postgresql://postgres:postgres@localhost:5432/template",
|
|
},
|
|
}),
|
|
typescript: {
|
|
outputFile: path.resolve(dirname, "generated-types.ts"),
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Install + typecheck**
|
|
|
|
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-cms`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/core-cms pnpm-lock.yaml
|
|
git commit -m "feat(core-cms): compose pages + siteSettings + header into payload config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.16: Wire marketingPagesRouter + navigationRouter into core-api
|
|
|
|
- [ ] **Step 1: Add to `packages/core-api/package.json` dependencies**
|
|
|
|
```json
|
|
"@repo/marketing-pages": "workspace:*",
|
|
"@repo/navigation": "workspace:*",
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `packages/core-api/src/root.ts`**
|
|
|
|
```typescript
|
|
import { router } from "@repo/core-shared/trpc/init";
|
|
import { authRouter } from "@repo/auth/api";
|
|
import { blogRouter } from "@repo/blog/api";
|
|
import { marketingPagesRouter } from "@repo/marketing-pages/api";
|
|
import { navigationRouter } from "@repo/navigation/api";
|
|
|
|
export const appRouter = router({
|
|
auth: authRouter,
|
|
blog: blogRouter,
|
|
marketingPages: marketingPagesRouter,
|
|
navigation: navigationRouter,
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|
|
```
|
|
|
|
- [ ] **Step 3: Install + typecheck**
|
|
|
|
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/marketing-pages --filter @repo/navigation`
|
|
Expected: ALL PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/core-api pnpm-lock.yaml
|
|
git commit -m "feat(core-api): compose marketingPages + navigation routers into appRouter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4.17: Final verification + cms smoke test
|
|
|
|
- [ ] **Step 1: Run all tests**
|
|
|
|
Run: `pnpm test --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/blog --filter @repo/auth --filter @repo/core-shared`
|
|
Expected: PASS. Marketing-pages: 16, Navigation: 4, Blog: 26, Auth: 24, Core-shared: 26 = 96 total.
|
|
|
|
- [ ] **Step 2: Repo-wide typecheck**
|
|
|
|
Run: `pnpm typecheck`
|
|
Expected: PASS for all packages except pre-existing `@repo/api` and `@repo/ui` failures.
|
|
|
|
- [ ] **Step 3: CMS admin smoke test**
|
|
|
|
Verify postgres: `docker ps | grep postgres`. If not running: `docker compose up -d postgres`.
|
|
|
|
Start cms in background:
|
|
```bash
|
|
pnpm dev --filter @repo/cms &
|
|
DEV_PID=$!
|
|
sleep 15
|
|
```
|
|
|
|
Test endpoints:
|
|
```bash
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/pages
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/site-settings
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/header
|
|
```
|
|
Expected: ALL return 200.
|
|
|
|
Kill:
|
|
```bash
|
|
kill $DEV_PID 2>/dev/null
|
|
pkill -f "next.*3001" 2>/dev/null
|
|
```
|
|
|
|
- [ ] **Step 4: Regenerate types**
|
|
|
|
Run: `cd apps/cms && pnpm generate:types`
|
|
|
|
Verify:
|
|
```bash
|
|
cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor
|
|
grep -E "Page|SiteSetting|Header" packages/core-cms/src/generated-types.ts | wc -l
|
|
```
|
|
Expected: at least 5 occurrences.
|
|
|
|
- [ ] **Step 5: Commit regenerated types if changed**
|
|
|
|
```bash
|
|
git add packages/core-cms/src/generated-types.ts
|
|
git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Pages, SiteSettings, Header"
|
|
```
|
|
|
|
---
|
|
|
|
## Plan 4 Done Criteria
|
|
|
|
- [ ] Marketing-pages tests pass (16)
|
|
- [ ] Navigation tests pass (4)
|
|
- [ ] All other features still pass (blog 26, auth 24)
|
|
- [ ] `apps/cms` admin serves `/admin/collections/pages`, `/admin/globals/site-settings`, `/admin/globals/header` — all 200
|
|
- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Pages, SiteSettings, Header
|
|
- [ ] `core-cms/src/payload.config.ts` registers `pages` collection + `siteSettings` and `header` globals
|
|
- [ ] `core-api/src/root.ts` exposes 4 namespaces: `auth`, `blog`, `marketingPages`, `navigation`
|
|
|
|
**Next plan:** Plan 5 — App + UI integration. Wire `core-trpc` (client + per-framework providers), add tRPC route handlers in `apps/web-next` and `apps/web-tanstack`, build example pages (`/`, `/about`, `/blog/[slug]`) that consume the feature ui/query helpers. First plan that actually renders the features in a browser.
|