From 74cb9bd29aaaff32af272dae0702bc3b466e94b0 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 4 May 2026 22:07:00 +0200 Subject: [PATCH] docs(plan): add Plan 2 (Blog feature canonical migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 tasks migrating the existing content/articles domain into a new @repo/blog package with full canonical layer shape. Per-feature InversifyJS container, payload-articles repository using getPayload + core-cms config, simplified schema (author as text, no featuredImage) with TODOs to restore in Plan 3 when auth + media features are migrated. Wires blog/cms into core-cms and blog/api into core-api. Plan 2 of 6 — total revised from original 4 during execution to keep each plan digestible (Plan 1 in-flight numbering preserved). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-04-plan-2-blog-feature.md | 1824 +++++++++++++++++ 1 file changed, 1824 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md diff --git a/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md b/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md new file mode 100644 index 0000000..45825f8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md @@ -0,0 +1,1824 @@ +# Vertical Refactor — Plan 2: Blog Feature (Canonical Migration) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the existing `content/articles` domain (currently inside `packages/core` and `packages/cms-core`) into a new vertical `packages/blog` feature package with the full canonical layered shape: entities → application → infrastructure → interface-adapters → di → integrations/cms → integrations/api → ui. Wire the feature's `/cms` export into `core-cms` composition and its `/api` export into `core-api` composition. Establish the per-feature InversifyJS container pattern. Pattern proven here will be replicated for `auth`, `media`, `marketing-pages`, and `navigation` in subsequent plans. + +**Architecture:** One new package (`@repo/blog`) with the full canonical structure. Per-feature InversifyJS container holds the `IArticlesRepository` binding (mock by default for tests; payload-backed in production via `getPayload({ config })` from `@repo/core-cms`). tRPC procedures in `integrations/api/router.ts` call controllers, which Zod-parse and delegate to use-cases, which resolve the repo via the feature container. Articles collection moves from `cms-core` into `blog/integrations/cms/collections/`. + +**Tech Stack:** InversifyJS 6.x with reflect-metadata, Vitest 3.x, Zod 3, tRPC 11, Payload 3.14 Local API. + +**Plan position:** Plan 2 of 6 in the vertical-refactor sequence (revised from original 4 during execution to keep each plan digestible): +- Plan 1 ✅ Foundation — core-* package scaffolds + core-shared primitives + core-cms stub +- **Plan 2 (this doc):** Blog feature (canonical) — full layered shape, simplified schema (no cross-feature relationships) +- Plan 3: Auth + Media features + restore blog's `author` relationship and `featuredImage` +- Plan 4: Marketing-pages + Navigation features +- Plan 5: App + UI integration — core-trpc client/providers, route handlers, example pages +- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite + +**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` + +--- + +## Schema simplifications in this plan + +The existing `Articles` collection in `packages/cms-core/src/collections/articles/fields.ts` declares two cross-feature references: +- `author: relationship → users` (users collection is owned by the auth feature, migrated in Plan 3) +- `featuredImage: upload → media` (media collection is owned by the media feature, migrated in Plan 3) + +To keep Plan 2 atomic, the migrated `articles.ts` collection here uses **simplified placeholders**: +- `author` becomes a `text` field (string). +- `featuredImage` is omitted entirely. + +**Plan 3 restores both fields to their relationship/upload form.** This is called out explicitly in those Plan 3 tasks. The simplification is also documented in a TODO comment in the new `articles.ts` so an engineer reading the file later understands. + +The domain `Article.content` field is also widened: the existing entity has `content: z.string()` but Payload's actual storage is rich-text JSON. New entity uses `content: z.unknown()` to honestly model rich-text content (matches spec §11.9's `body: unknown` pattern). The mock repository's tests are adjusted accordingly. + +--- + +## File Structure (this plan creates/modifies) + +**Create — new `@repo/blog` package:** +- `packages/blog/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` +- `packages/blog/src/index.ts` — barrel export +- `packages/blog/src/entities/article.ts` + `article.test.ts` +- `packages/blog/src/entities/errors.ts` +- `packages/blog/src/application/repositories/articles-repository.interface.ts` +- `packages/blog/src/application/use-cases/get-articles.use-case.ts` + `get-articles.use-case.test.ts` +- `packages/blog/src/application/use-cases/create-article.use-case.ts` + `create-article.use-case.test.ts` +- `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts` +- `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` + `payload-articles.repository.test.ts` +- `packages/blog/src/interface-adapters/controllers/articles.controller.ts` + `articles.controller.test.ts` +- `packages/blog/src/di/symbols.ts` +- `packages/blog/src/di/module.ts` +- `packages/blog/src/di/container.ts` + `container.test.ts` +- `packages/blog/src/integrations/cms/collections/articles.ts` +- `packages/blog/src/integrations/cms/index.ts` +- `packages/blog/src/integrations/api/router.ts` +- `packages/blog/src/ui/query.ts` +- `packages/blog/tests/articles.feature.test.ts` + +**Modify:** +- `packages/core-cms/src/payload.config.ts` — import + register `articles` from `@repo/blog/cms` +- `packages/core-api/src/root.ts` — import + register `blogRouter` from `@repo/blog/api` +- `tsconfig.base.json` — add `@repo/blog`, `@repo/blog/cms`, `@repo/blog/api` aliases +- `apps/cms/package.json` — add `@repo/blog` dependency (so the workspace resolves the schema import) + +**Do NOT touch:** +- `packages/core/` (still owns auth domain — left intact for Plan 3) +- `packages/api/` (still wires the old content router; orphaned but unused after Plan 2 — actually used by ZERO consumers since reference apps are empty) +- `packages/cms-core/src/collections/articles/` — the OLD location. After Plan 2, both old and new exist; Plan 6 deletes `cms-core` entirely. Apps/cms reads from `core-cms` which only sees the NEW location. + +--- + +## Phase 1: Blog package skeleton + entity layer + +### Task 2.1: Scaffold @repo/blog package + +**Files:** +- Create: `packages/blog/package.json` +- Create: `packages/blog/tsconfig.json` +- Create: `packages/blog/turbo.json` +- Create: `packages/blog/vitest.config.ts` +- Create: `packages/blog/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@repo/blog", + "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-cms": "workspace:*", + "@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 tsconfig.json** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +> Note: `@/*` path alias is feature-local (resolves to `packages/blog/src/*`). This is the same pattern `packages/core/tsconfig.json` already uses. + +- [ ] **Step 3: Create turbo.json** + +```json +{ + "extends": ["//"], + "tags": ["feature"] +} +``` + +- [ ] **Step 4: Create vitest.config.ts** + +```typescript +import { baseVitestConfig } from "@repo/typescript-config/vitest.base"; + +export default baseVitestConfig; +``` + +- [ ] **Step 5: Create empty index.ts** + +```typescript +export {}; +``` + +- [ ] **Step 6: Add path aliases to root tsconfig.base.json** + +Read the current `tsconfig.base.json`. Add three new entries to `compilerOptions.paths` (alphabetically — between `@repo/core-ui` and `@repo/core-shared` if existing list is sorted; otherwise just at the end of the paths object before the closing brace): + +```json +"@repo/blog": ["packages/blog/src/index.ts"], +"@repo/blog/cms": ["packages/blog/src/integrations/cms/index.ts"], +"@repo/blog/api": ["packages/blog/src/integrations/api/router.ts"] +``` + +- [ ] **Step 7: Install + verify** + +Run: `pnpm install` +Expected: `@repo/blog` registered in workspace; lockfile updated. + +- [ ] **Step 8: Commit** + +```bash +git add packages/blog tsconfig.base.json pnpm-lock.yaml +git commit -m "feat(blog): scaffold empty package with feature tag + path aliases" +``` + +--- + +### Task 2.2: Port Article entity (with content widened to unknown) + +**Files:** +- Create: `packages/blog/src/entities/article.ts` +- Create: `packages/blog/src/entities/article.test.ts` +- Create: `packages/blog/src/entities/errors.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/entities/article.test.ts +import { describe, expect, it } from "vitest"; +import { articleSchema, articleStatusSchema, type Article } from "./article"; + +describe("articleSchema", () => { + it("accepts a minimal valid article with default status", () => { + const result = articleSchema.parse({ + id: "abc", + title: "Hello", + slug: "hello", + content: { type: "doc", children: [] }, + authorId: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(result.status).toBe("draft"); + }); + + it("accepts unknown rich-text content", () => { + const result = articleSchema.parse({ + id: "abc", + title: "Hello", + slug: "hello", + content: "any string is also fine", + authorId: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(result.content).toBe("any string is also fine"); + }); + + it("rejects empty title", () => { + expect(() => + articleSchema.parse({ + id: "a", + title: "", + slug: "s", + content: null, + authorId: "u", + createdAt: new Date(), + updatedAt: new Date(), + }), + ).toThrow(); + }); + + it("rejects title over 255 chars", () => { + expect(() => + articleSchema.parse({ + id: "a", + title: "x".repeat(256), + slug: "s", + content: null, + authorId: "u", + createdAt: new Date(), + updatedAt: new Date(), + }), + ).toThrow(); + }); +}); + +describe("articleStatusSchema", () => { + it("accepts 'draft' and 'published'", () => { + expect(articleStatusSchema.parse("draft")).toBe("draft"); + expect(articleStatusSchema.parse("published")).toBe("published"); + }); + + it("rejects unknown status", () => { + expect(() => articleStatusSchema.parse("archived")).toThrow(); + }); +}); + +describe("Article type", () => { + it("widens content to unknown", () => { + const _example: Article = { + id: "x", + title: "t", + slug: "s", + content: { whatever: true }, + status: "draft", + authorId: "u", + createdAt: new Date(), + updatedAt: new Date(), + }; + expect(_example).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts` +Expected: FAIL — "Cannot find module './article'" + +- [ ] **Step 3: Implement article entity** + +```typescript +// packages/blog/src/entities/article.ts +import { z } from "zod"; + +export const articleStatusSchema = z.enum(["draft", "published"]); + +export const articleSchema = z.object({ + id: z.string(), + title: z.string().min(1).max(255), + slug: z.string().min(1).max(255), + content: z.unknown(), + status: articleStatusSchema.default("draft"), + authorId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type Article = z.infer; +export type ArticleStatus = z.infer; +``` + +- [ ] **Step 4: Create errors.ts (domain-specific errors)** + +```typescript +// packages/blog/src/entities/errors.ts +export class ArticleNotFoundError extends Error { + constructor(message = "Article not found", options?: ErrorOptions) { + super(message, options); + } +} + +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} +``` + +> Note: `InputParseError` is duplicated here because the new architecture has each feature own its errors (no shared `@repo/core/entities/errors`). The class is small. + +- [ ] **Step 5: Run — expect pass** + +Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts` +Expected: PASS — 7 tests. + +- [ ] **Step 6: Commit** + +```bash +git add packages/blog/src/entities +git commit -m "feat(blog): add Article entity with rich-text content + domain errors" +``` + +--- + +## Phase 2: Application layer (use-cases + repository interface) + +### Task 2.3: Define IArticlesRepository interface + +**Files:** +- Create: `packages/blog/src/application/repositories/articles-repository.interface.ts` + +- [ ] **Step 1: Implement** (no test — interface has no behavior; exercised through use-case tests) + +```typescript +// packages/blog/src/application/repositories/articles-repository.interface.ts +import type { Article } from "@/entities/article"; + +export interface IArticlesRepository { + getArticle(id: string): Promise
; + getArticleBySlug(slug: string): Promise
; + getArticles(options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; + }): Promise; + createArticle(input: Article): Promise
; + updateArticle( + id: string, + input: Partial
, + ): Promise
; +} +``` + +> Note: `getArticleBySlug` is added beyond the original `packages/core` interface — needed by `articleBySlug` tRPC procedure (the canonical example query in spec §11.15). + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/blog && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/blog/src/application/repositories +git commit -m "feat(blog): add IArticlesRepository interface" +``` + +--- + +### Task 2.4: Implement getArticles use-case + tests + +**Files:** +- Create: `packages/blog/src/application/use-cases/get-articles.use-case.ts` +- Create: `packages/blog/src/application/use-cases/get-articles.use-case.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/application/use-cases/get-articles.use-case.test.ts +import { beforeEach, describe, expect, it } from "vitest"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import { getArticlesUseCase } from "./get-articles.use-case"; + +describe("getArticlesUseCase", () => { + let repo: MockArticlesRepository; + + beforeEach(() => { + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + } + repo = new MockArticlesRepository(); + blogContainer + .bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(repo); + }); + + it("returns all articles with no filters", async () => { + const now = new Date(); + await repo.createArticle({ + id: "1", + title: "A", + slug: "a", + content: null, + status: "draft", + authorId: "u1", + createdAt: now, + updatedAt: now, + }); + const result = await getArticlesUseCase(); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("1"); + }); + + it("filters by status", async () => { + const now = new Date(); + await repo.createArticle({ + id: "1", + title: "A", + slug: "a", + content: null, + status: "draft", + authorId: "u1", + createdAt: now, + updatedAt: now, + }); + await repo.createArticle({ + id: "2", + title: "B", + slug: "b", + content: null, + status: "published", + authorId: "u1", + createdAt: now, + updatedAt: now, + }); + const result = await getArticlesUseCase({ status: "published" }); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("2"); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/application/use-cases/get-articles.use-case.test.ts` +Expected: FAIL — multiple "Cannot find module" errors (the test imports from `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository`, none of which exist yet). + +> This is intentional: TDD here means the use-case test exercises the full DI container shape AND the mock repo. Both will be implemented in subsequent tasks. For now, just write the use-case to satisfy its direct dependencies. + +- [ ] **Step 3: Implement use-case** + +```typescript +// packages/blog/src/application/use-cases/get-articles.use-case.ts +import type { Article } from "@/entities/article"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; + +export async function getArticlesUseCase(options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; +}): Promise { + const repo = blogContainer.get( + BLOG_SYMBOLS.IArticlesRepository, + ); + return repo.getArticles(options); +} +``` + +> Note: This still won't compile or run because `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository` don't exist yet. Test will continue to fail. That's expected — Tasks 2.5-2.7 implement those, then this test passes. + +- [ ] **Step 4: Commit (test + impl, both currently failing — establishes the contract)** + +```bash +git add packages/blog/src/application/use-cases/get-articles.use-case.ts packages/blog/src/application/use-cases/get-articles.use-case.test.ts +git commit -m "feat(blog): add getArticlesUseCase (test red until DI + mock repo exist)" +``` + +--- + +### Task 2.5: Implement createArticle use-case + tests + +**Files:** +- Create: `packages/blog/src/application/use-cases/create-article.use-case.ts` +- Create: `packages/blog/src/application/use-cases/create-article.use-case.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/application/use-cases/create-article.use-case.test.ts +import { beforeEach, describe, expect, it } from "vitest"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import { createArticleUseCase } from "./create-article.use-case"; + +describe("createArticleUseCase", () => { + let repo: MockArticlesRepository; + + beforeEach(() => { + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + } + repo = new MockArticlesRepository(); + blogContainer + .bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(repo); + }); + + it("creates an article in draft status with auto-generated slug", async () => { + const result = await createArticleUseCase({ + title: "Hello World", + content: "body", + authorId: "u1", + }); + expect(result.title).toBe("Hello World"); + expect(result.slug).toBe("hello-world"); + expect(result.status).toBe("draft"); + expect(result.id).toBeTruthy(); + + const stored = await repo.getArticle(result.id); + expect(stored).toBeDefined(); + }); + + it("uses provided slug when supplied", async () => { + const result = await createArticleUseCase({ + title: "Whatever", + content: "body", + authorId: "u1", + slug: "custom-slug", + }); + expect(result.slug).toBe("custom-slug"); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/application/use-cases/create-article.use-case.test.ts` +Expected: FAIL — module-not-found errors. + +- [ ] **Step 3: Implement use-case** + +```typescript +// packages/blog/src/application/use-cases/create-article.use-case.ts +import type { Article } from "@/entities/article"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; + +function generateSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export async function createArticleUseCase(input: { + title: string; + content: unknown; + authorId: string; + slug?: string; +}): Promise
{ + const repo = blogContainer.get( + BLOG_SYMBOLS.IArticlesRepository, + ); + + const now = new Date(); + const article: Article = { + id: crypto.randomUUID(), + title: input.title, + slug: input.slug ?? generateSlug(input.title), + content: input.content, + status: "draft", + authorId: input.authorId, + createdAt: now, + updatedAt: now, + }; + + return repo.createArticle(article); +} +``` + +- [ ] **Step 4: Commit (test still failing — DI not yet implemented)** + +```bash +git add packages/blog/src/application/use-cases/create-article.use-case.ts packages/blog/src/application/use-cases/create-article.use-case.test.ts +git commit -m "feat(blog): add createArticleUseCase (test red until DI + mock repo exist)" +``` + +--- + +## Phase 3: Infrastructure layer (mock + payload repos) + +### Task 2.6: Mock articles repository + +**Files:** +- Create: `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts` + +- [ ] **Step 1: Implement** (no separate test — exercised by use-case tests) + +```typescript +// packages/blog/src/infrastructure/repositories/mock-articles.repository.ts +import "reflect-metadata"; +import { injectable } from "inversify"; + +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import type { Article } from "@/entities/article"; + +@injectable() +export class MockArticlesRepository implements IArticlesRepository { + private _articles: Article[] = []; + + async getArticle(id: string): Promise
{ + return this._articles.find((a) => a.id === id); + } + + async getArticleBySlug(slug: string): Promise
{ + return this._articles.find((a) => a.slug === slug); + } + + async getArticles(options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; + }): Promise { + let result = [...this._articles]; + if (options?.status) { + result = result.filter((a) => a.status === options.status); + } + if (options?.authorId) { + result = result.filter((a) => a.authorId === options.authorId); + } + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 50; + return result.slice(offset, offset + limit); + } + + async createArticle(input: Article): Promise
{ + this._articles.push(input); + return input; + } + + async updateArticle( + id: string, + input: Partial
, + ): Promise
{ + const index = this._articles.findIndex((a) => a.id === id); + if (index === -1) return undefined; + const current = this._articles[index]; + if (!current) return undefined; + const updated: Article = { ...current, ...input, id: current.id }; + this._articles[index] = updated; + return updated; + } +} +``` + +- [ ] **Step 2: Verify it compiles** (use-case tests still won't pass — DI container not yet implemented) + +Run: `cd packages/blog && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/blog/src/infrastructure/repositories/mock-articles.repository.ts +git commit -m "feat(blog): add MockArticlesRepository for tests" +``` + +--- + +### Task 2.7: Per-feature DI container (symbols, module, container) + +**Files:** +- Create: `packages/blog/src/di/symbols.ts` +- Create: `packages/blog/src/di/module.ts` +- Create: `packages/blog/src/di/container.ts` +- Create: `packages/blog/src/di/container.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/di/container.test.ts +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { blogContainer } from "./container"; +import { BLOG_SYMBOLS } from "./symbols"; +import { BlogModule } from "./module"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; + +describe("blogContainer", () => { + beforeEach(() => { + blogContainer.unbindAll(); + blogContainer.load(BlogModule); + }); + + afterEach(() => { + blogContainer.unbindAll(); + }); + + it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => { + const repo = blogContainer.get( + BLOG_SYMBOLS.IArticlesRepository, + ); + expect(repo).toBeInstanceOf(MockArticlesRepository); + }); + + it("supports rebinding to a custom repo", () => { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + const custom = new MockArticlesRepository(); + blogContainer + .bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(custom); + const resolved = blogContainer.get( + BLOG_SYMBOLS.IArticlesRepository, + ); + expect(resolved).toBe(custom); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts` +Expected: FAIL — module-not-found errors. + +- [ ] **Step 3: Implement symbols.ts** + +```typescript +// packages/blog/src/di/symbols.ts +export const BLOG_SYMBOLS = { + IArticlesRepository: Symbol.for("blog:IArticlesRepository"), +} as const; +``` + +> Note: Symbol descriptions are namespaced (`blog:IArticlesRepository`) so debug output is clear and there's no collision with other features' symbols at the JavaScript level (each feature has its own container, so collisions can't actually happen — this is just hygiene). + +- [ ] **Step 4: Implement module.ts** + +```typescript +// packages/blog/src/di/module.ts +import { ContainerModule, type interfaces } from "inversify"; + +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import { BLOG_SYMBOLS } from "./symbols"; + +export const BlogModule = new ContainerModule((bind: interfaces.Bind) => { + bind(BLOG_SYMBOLS.IArticlesRepository).to( + MockArticlesRepository, + ); +}); +``` + +> Note: Default binding is `MockArticlesRepository`. The payload-backed repo (Task 2.8) is bound at app boot (Plan 5) by rebinding before serving requests. Tests rebind per `beforeEach`. This keeps the package usable in isolation (e.g., for unit tests, Storybook dev) without requiring a database. + +- [ ] **Step 5: Implement container.ts** + +```typescript +// packages/blog/src/di/container.ts +import "reflect-metadata"; +import { Container } from "inversify"; +import { BlogModule } from "./module"; + +export const blogContainer = new Container({ defaultScope: "Singleton" }); +blogContainer.load(BlogModule); +``` + +- [ ] **Step 6: Run — expect pass** + +Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 7: Run the previously-red use-case tests** + +Run: `cd packages/blog && pnpm vitest run src/application/use-cases` +Expected: PASS — 4 tests (2 from `getArticles`, 2 from `createArticle`). + +- [ ] **Step 8: Commit** + +```bash +git add packages/blog/src/di +git commit -m "feat(blog): add per-feature InversifyJS container (symbols, module, container)" +``` + +--- + +### Task 2.8: Payload articles repository + +**Files:** +- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` +- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts +import { describe, expect, it, vi } from "vitest"; +import { PayloadArticlesRepository } from "./payload-articles.repository"; + +// Mock the payload module so we don't need a real DB +vi.mock("payload", () => ({ + getPayload: vi.fn(), +})); + +vi.mock("@repo/core-cms", () => ({ + default: {} as never, +})); + +describe("PayloadArticlesRepository", () => { + it("maps a Payload doc to a domain Article on getArticleBySlug", async () => { + const { getPayload } = await import("payload"); + const findMock = vi.fn().mockResolvedValue({ + docs: [ + { + id: "p-123", + title: "Hello", + slug: "hello", + content: { type: "doc", children: [] }, + status: "published", + author: "u1", + createdAt: "2026-05-04T12:00:00.000Z", + updatedAt: "2026-05-04T12:00:00.000Z", + }, + ], + }); + (getPayload as ReturnType).mockResolvedValue({ + find: findMock, + }); + + const repo = new PayloadArticlesRepository(); + const result = await repo.getArticleBySlug("hello"); + + expect(findMock).toHaveBeenCalledWith({ + collection: "articles", + where: { slug: { equals: "hello" } }, + limit: 1, + overrideAccess: false, + }); + expect(result?.id).toBe("p-123"); + expect(result?.slug).toBe("hello"); + expect(result?.status).toBe("published"); + expect(result?.authorId).toBe("u1"); + expect(result?.createdAt).toBeInstanceOf(Date); + }); + + it("returns undefined when slug is not found", async () => { + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue({ + find: vi.fn().mockResolvedValue({ docs: [] }), + }); + + const repo = new PayloadArticlesRepository(); + const result = await repo.getArticleBySlug("missing"); + expect(result).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` +Expected: FAIL — "Cannot find module './payload-articles.repository'" + +- [ ] **Step 3: Implement payload-articles repository** + +```typescript +// packages/blog/src/infrastructure/repositories/payload-articles.repository.ts +import "reflect-metadata"; +import { injectable } from "inversify"; +import { getPayload } from "payload"; + +import config from "@repo/core-cms"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import type { Article } from "@/entities/article"; + +type PayloadArticleDoc = { + id: string | number; + title?: string | null; + slug?: string | null; + content?: unknown; + status?: string | null; + author?: string | number | { id: string | number } | null; + createdAt?: string | null; + updatedAt?: string | null; +}; + +function mapDoc(doc: PayloadArticleDoc): Article { + const authorId = + typeof doc.author === "object" && doc.author !== null + ? String(doc.author.id) + : doc.author != null + ? String(doc.author) + : ""; + return { + id: String(doc.id), + title: doc.title ?? "", + slug: doc.slug ?? "", + content: doc.content ?? null, + status: (doc.status === "published" ? "published" : "draft"), + authorId, + createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), + updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0), + }; +} + +@injectable() +export class PayloadArticlesRepository implements IArticlesRepository { + async getArticle(id: string): Promise
{ + const payload = await getPayload({ config }); + try { + const doc = await payload.findByID({ + collection: "articles", + id, + overrideAccess: false, + }); + return mapDoc(doc as PayloadArticleDoc); + } catch { + return undefined; + } + } + + async getArticleBySlug(slug: string): Promise
{ + const payload = await getPayload({ config }); + const result = await payload.find({ + collection: "articles", + where: { slug: { equals: slug } }, + limit: 1, + overrideAccess: false, + }); + const doc = result.docs[0] as PayloadArticleDoc | undefined; + return doc ? mapDoc(doc) : undefined; + } + + async getArticles(options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; + }): Promise { + const payload = await getPayload({ config }); + const where: Record = {}; + if (options?.status) where.status = { equals: options.status }; + if (options?.authorId) where.author = { equals: options.authorId }; + + const result = await payload.find({ + collection: "articles", + where, + 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 PayloadArticleDoc)); + } + + async createArticle(input: Article): Promise
{ + const payload = await getPayload({ config }); + const created = await payload.create({ + collection: "articles", + data: { + title: input.title, + slug: input.slug, + content: input.content, + status: input.status, + author: input.authorId, + } as never, + overrideAccess: false, + }); + return mapDoc(created as PayloadArticleDoc); + } + + async updateArticle( + id: string, + input: Partial
, + ): Promise
{ + const payload = await getPayload({ config }); + try { + const updated = await payload.update({ + collection: "articles", + id, + data: { + ...(input.title !== undefined && { title: input.title }), + ...(input.slug !== undefined && { slug: input.slug }), + ...(input.content !== undefined && { content: input.content }), + ...(input.status !== undefined && { status: input.status }), + ...(input.authorId !== undefined && { author: input.authorId }), + } as never, + overrideAccess: false, + }); + return mapDoc(updated as PayloadArticleDoc); + } catch { + return undefined; + } + } +} +``` + +> Note: `as never` casts on the Payload `data` arguments are needed because Payload's generated types (which we don't have for the empty-collection core-cms config) would normally constrain these. With empty `collections: []`, the generated types don't include `'articles'`, so we cast through `never`. After Plan 3 wires articles into core-cms.collections AND `pnpm generate:types` runs, these casts can potentially be removed — but for Plan 2's purpose (decoupled feature with deferred wiring) the casts are correct. + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/blog/src/infrastructure/repositories/payload-articles.repository.ts packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts +git commit -m "feat(blog): add PayloadArticlesRepository with doc-to-entity mapping" +``` + +--- + +## Phase 4: Interface-adapters (controllers) + +### Task 2.9: Articles controller + tests + +**Files:** +- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.ts` +- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/interface-adapters/controllers/articles.controller.test.ts +import { beforeEach, describe, expect, it } from "vitest"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { InputParseError } from "@/entities/errors"; +import { + createArticleController, + getArticlesController, + getArticleBySlugController, +} from "./articles.controller"; + +describe("articles controller", () => { + let repo: MockArticlesRepository; + + beforeEach(() => { + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + } + repo = new MockArticlesRepository(); + blogContainer + .bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(repo); + }); + + describe("createArticleController", () => { + it("creates an article on valid input", async () => { + const result = await createArticleController({ + title: "Hello", + content: "body", + authorId: "u1", + }); + expect(result.title).toBe("Hello"); + }); + + it("throws InputParseError on missing title", async () => { + await expect( + createArticleController({ content: "body", authorId: "u1" }), + ).rejects.toBeInstanceOf(InputParseError); + }); + }); + + describe("getArticlesController", () => { + it("returns array on valid input", async () => { + const result = await getArticlesController({}); + expect(result).toEqual([]); + }); + + it("throws InputParseError on invalid input shape", async () => { + await expect( + getArticlesController({ limit: "not a number" } as unknown as Record< + string, + unknown + >), + ).rejects.toBeInstanceOf(InputParseError); + }); + }); + + describe("getArticleBySlugController", () => { + it("returns undefined for missing slug", async () => { + const result = await getArticleBySlugController({ slug: "nope" }); + expect(result).toBeUndefined(); + }); + + it("throws InputParseError on missing slug", async () => { + await expect( + getArticleBySlugController({} as { slug: string }), + ).rejects.toBeInstanceOf(InputParseError); + }); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts` +Expected: FAIL — "Cannot find module './articles.controller'" + +- [ ] **Step 3: Implement controller** + +```typescript +// packages/blog/src/interface-adapters/controllers/articles.controller.ts +import { z } from "zod"; + +import { InputParseError } from "@/entities/errors"; +import type { Article } from "@/entities/article"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; +import { createArticleUseCase } from "@/application/use-cases/create-article.use-case"; + +const createInputSchema = z.object({ + title: z.string().min(1).max(255), + content: z.unknown(), + authorId: z.string(), + slug: z.string().optional(), +}); + +const getInputSchema = z.object({ + status: z.string().optional(), + authorId: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getBySlugInputSchema = z.object({ + slug: z.string().min(1), +}); + +export async function createArticleController( + input: Partial>, +): Promise
{ + const parsed = createInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid create-article input", { + cause: parsed.error, + }); + } + return createArticleUseCase(parsed.data); +} + +export async function getArticlesController( + input: Partial>, +): Promise { + const parsed = getInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-articles input", { + cause: parsed.error, + }); + } + return getArticlesUseCase(parsed.data); +} + +export async function getArticleBySlugController(input: { + slug: string; +}): Promise
{ + const parsed = getBySlugInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-article-by-slug input", { + cause: parsed.error, + }); + } + const repo = blogContainer.get( + BLOG_SYMBOLS.IArticlesRepository, + ); + return repo.getArticleBySlug(parsed.data.slug); +} +``` + +> Note: `getArticleBySlugController` calls the repo directly rather than going through a separate `getArticleBySlug.use-case.ts`. This is acceptable per spec addendum v5 — for trivial pass-through reads, a use-case file would be empty ceremony. The pattern emerges if/when this read needs caching, authorization decisions, or other application logic. (Plan 3 leaves it as-is; create one in a later plan if behavior justifies it.) + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts` +Expected: PASS — 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/blog/src/interface-adapters +git commit -m "feat(blog): add articles controller with Zod validation" +``` + +--- + +## Phase 5: Integrations (CMS schema + tRPC API) + +### Task 2.10: Articles collection (simplified — no cross-feature relations) + +**Files:** +- Create: `packages/blog/src/integrations/cms/collections/articles.ts` +- Create: `packages/blog/src/integrations/cms/index.ts` + +- [ ] **Step 1: Implement collection (simplified — see schema simplifications section)** + +```typescript +// packages/blog/src/integrations/cms/collections/articles.ts +import type { CollectionConfig } from "payload"; +import { slugifyIfMissing } from "@repo/core-shared/payload"; + +export const articles: CollectionConfig = { + slug: "articles", + admin: { + useAsTitle: "title", + defaultColumns: ["title", "status", "author", "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: "content", + type: "richText", + }, + { + name: "status", + type: "select", + options: [ + { label: "Draft", value: "draft" }, + { label: "Published", value: "published" }, + ], + defaultValue: "draft", + required: true, + admin: { + position: "sidebar", + }, + }, + // TODO(plan-3): Restore as `relationship → users` once auth feature is migrated. + { + name: "author", + type: "text", + required: true, + admin: { + position: "sidebar", + description: + "Temporary text field; restored to users relationship in Plan 3.", + }, + }, + // TODO(plan-3): Restore `featuredImage: upload → media` once media feature is migrated. + { + name: "publishedAt", + type: "date", + admin: { + position: "sidebar", + date: { + pickerAppearance: "dayAndTime", + }, + }, + }, + ], +}; +``` + +> Note: `slugifyIfMissing` from `@repo/core-shared/payload` replaces the inline `autoGenerateSlug` hook from `packages/cms-core/src/collections/articles/hooks/before-change.ts`. Same behavior, generic helper. + +- [ ] **Step 2: Implement integrations/cms barrel** + +```typescript +// packages/blog/src/integrations/cms/index.ts +export { articles } from "./collections/articles"; +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd packages/blog && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/blog/src/integrations/cms +git commit -m "feat(blog): add articles collection (simplified — no cross-feature refs)" +``` + +--- + +### Task 2.11: Wire articles into core-cms composition + +**Files:** +- Modify: `packages/core-cms/src/payload.config.ts` +- Modify: `packages/core-cms/package.json` (add `@repo/blog` dependency) + +- [ ] **Step 1: Add @repo/blog to core-cms dependencies** + +Add inside `packages/core-cms/package.json`'s `dependencies` block: + +```json +"@repo/blog": "workspace:*", +``` + +> Note: This is the spec §4.2 composition exception — `core-cms` may import feature `/cms` exports. + +- [ ] **Step 2: Update payload.config.ts to register articles** + +Read current `packages/core-cms/src/payload.config.ts`. Replace it with: + +```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 { articles } from "@repo/blog/cms"; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default buildConfig({ + editor: lexicalEditor(), + collections: [articles], + globals: [], + 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 (picks up workspace dep)** + +Run: `pnpm install` +Expected: install completes; `@repo/blog` available to `@repo/core-cms`. + +- [ ] **Step 4: Verify core-cms typechecks** + +Run: `pnpm typecheck --filter @repo/core-cms --filter @repo/blog` +Expected: PASS for both. + +- [ ] **Step 5: Regenerate Payload types** (now articles is in the config; types should include `Article`-shaped doc) + +Run: `cd apps/cms && pnpm generate:types` +Expected: writes new `packages/core-cms/src/generated-types.ts` containing an `Articles` interface. + +- [ ] **Step 6: Verify generated types compile** + +Run: `pnpm typecheck --filter @repo/core-cms` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/core-cms packages/blog pnpm-lock.yaml +git commit -m "feat(core-cms): compose @repo/blog/cms into payload config" +``` + +--- + +### Task 2.12: tRPC router (integrations/api) + +**Files:** +- Create: `packages/blog/src/integrations/api/router.ts` +- Create: `packages/blog/src/integrations/api/router.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/blog/src/integrations/api/router.test.ts +import { beforeEach, describe, expect, it } from "vitest"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { blogRouter } from "./router"; + +describe("blogRouter", () => { + let repo: MockArticlesRepository; + + beforeEach(() => { + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + } + repo = new MockArticlesRepository(); + blogContainer + .bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(repo); + }); + + it("exposes articleBySlug, listArticles, createArticle procedures", () => { + const procedureNames = Object.keys(blogRouter._def.procedures); + expect(procedureNames).toContain("articleBySlug"); + expect(procedureNames).toContain("listArticles"); + expect(procedureNames).toContain("createArticle"); + }); + + it("articleBySlug returns the article when present", async () => { + const now = new Date(); + await repo.createArticle({ + id: "1", + title: "T", + slug: "t", + content: null, + status: "draft", + authorId: "u1", + createdAt: now, + updatedAt: now, + }); + + const caller = blogRouter.createCaller({}); + const result = await caller.articleBySlug({ slug: "t" }); + expect(result?.id).toBe("1"); + }); + + it("listArticles returns all articles when no input is given", async () => { + const caller = blogRouter.createCaller({}); + const result = await caller.listArticles(); + expect(result).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts` +Expected: FAIL — "Cannot find module './router'" + +- [ ] **Step 3: Implement router** + +```typescript +// packages/blog/src/integrations/api/router.ts +import { z } from "zod"; +import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { + createArticleController, + getArticlesController, + getArticleBySlugController, +} from "@/interface-adapters/controllers/articles.controller"; + +export const blogRouter = router({ + articleBySlug: publicProcedure + .input(z.object({ slug: z.string().min(1) })) + .query(({ input }) => getArticleBySlugController(input)), + + listArticles: publicProcedure + .input( + z + .object({ + status: z.string().optional(), + authorId: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + }) + .optional(), + ) + .query(({ input }) => getArticlesController(input ?? {})), + + createArticle: publicProcedure + .input( + z.object({ + title: z.string().min(1).max(255), + content: z.unknown(), + authorId: z.string(), + slug: z.string().optional(), + }), + ) + .mutation(({ input }) => createArticleController(input)), +}); + +export type BlogRouter = typeof blogRouter; +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/blog/src/integrations/api +git commit -m "feat(blog): add tRPC router with articleBySlug + listArticles + createArticle" +``` + +--- + +### Task 2.13: Wire blogRouter into core-api + +**Files:** +- Modify: `packages/core-api/src/root.ts` +- Modify: `packages/core-api/package.json` (add `@repo/blog`) + +- [ ] **Step 1: Add @repo/blog to core-api dependencies** + +Add inside `packages/core-api/package.json`'s `dependencies` block: + +```json +"@repo/blog": "workspace:*", +``` + +- [ ] **Step 2: Update root.ts** + +Replace `packages/core-api/src/root.ts` with: + +```typescript +import { router } from "@repo/core-shared/trpc/init"; +import { blogRouter } from "@repo/blog/api"; + +export const appRouter = router({ + blog: blogRouter, +}); + +export type AppRouter = typeof appRouter; +``` + +- [ ] **Step 3: Install + verify** + +Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/blog` +Expected: both PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-api packages/blog pnpm-lock.yaml +git commit -m "feat(core-api): compose @repo/blog/api into appRouter under 'blog' namespace" +``` + +--- + +## Phase 6: UI helpers (deferred render — apps wire in Plan 5) + +### Task 2.14: ui/query.ts helper + +**Files:** +- Create: `packages/blog/src/ui/query.ts` + +- [ ] **Step 1: Implement** (no test — exercised when apps consume in Plan 5) + +```typescript +// packages/blog/src/ui/query.ts +// React Query option builders for blog feature procedures. +// Consumed by apps via the @repo/core-trpc client (wired in Plan 5). +// +// Example consumer (Plan 5): +// import { useQuery } from '@tanstack/react-query' +// import { trpc } from '@repo/core-trpc' +// import { articleBySlugQuery } from '@repo/blog/src/ui/query' +// const { data } = useQuery(articleBySlugQuery(trpc, slug)) +// +// Kept framework-agnostic here: takes the typed `trpc` client as an argument +// rather than importing it. This avoids importing @repo/core-trpc (which is +// a frontend platform package — feature shouldn't depend on it). + +type TrpcClient = { + blog: { + articleBySlug: { + queryOptions: (input: { slug: string }) => unknown; + }; + listArticles: { + queryOptions: (input?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; + }) => unknown; + }; + }; +}; + +export function articleBySlugQuery(client: TrpcClient, slug: string) { + return client.blog.articleBySlug.queryOptions({ slug }); +} + +export function listArticlesQuery( + client: TrpcClient, + options?: { status?: string; authorId?: string; limit?: number; offset?: number }, +) { + return client.blog.listArticles.queryOptions(options); +} +``` + +> Note: `TrpcClient` is locally typed structurally because `@repo/core-trpc` doesn't ship the React-aware client until Plan 5. The structural type is sufficient: when Plan 5 lands and apps call `articleBySlugQuery(trpc, slug)` with the real typed client, structural compatibility holds. Plan 5 may revise this to import from `@repo/core-trpc` once that's available. + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/blog && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/blog/src/ui +git commit -m "feat(blog): add ui/query.ts (framework-agnostic React Query option builders)" +``` + +--- + +## Phase 7: Wiring + barrel + feature test + +### Task 2.15: Wire blog package barrel + +**Files:** +- Modify: `packages/blog/src/index.ts` + +- [ ] **Step 1: Replace empty index.ts** + +```typescript +// packages/blog/src/index.ts +export type { Article, ArticleStatus } from "./entities/article"; +export { ArticleNotFoundError, InputParseError } from "./entities/errors"; +export { articleBySlugQuery, listArticlesQuery } from "./ui/query"; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/blog && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/blog/src/index.ts +git commit -m "feat(blog): wire root barrel (entity types + ui query helpers)" +``` + +--- + +### Task 2.16: Feature-level test (cross-layer) + +**Files:** +- Create: `packages/blog/tests/articles.feature.test.ts` + +- [ ] **Step 1: Write the test** + +```typescript +// packages/blog/tests/articles.feature.test.ts +// +// Feature-level test: exercises the full slice +// tRPC procedure -> controller -> use-case -> mock repo +// without going through a network or the actual Payload Local API. +// Verifies that the layers are correctly wired through the per-feature DI container. + +import { beforeEach, describe, expect, it } from "vitest"; +import { blogContainer } from "@/di/container"; +import { BLOG_SYMBOLS } from "@/di/symbols"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; +import { blogRouter } from "@/integrations/api/router"; + +describe("blog feature: article-by-slug end-to-end", () => { + let repo: MockArticlesRepository; + + beforeEach(() => { + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + } + repo = new MockArticlesRepository(); + blogContainer + .bind(BLOG_SYMBOLS.IArticlesRepository) + .toConstantValue(repo); + }); + + it("creates an article via tRPC, then fetches it back by slug", async () => { + const caller = blogRouter.createCaller({}); + + const created = await caller.createArticle({ + title: "The Vertical Refactor", + content: { type: "doc", children: [] }, + authorId: "u1", + slug: "vertical-refactor", + }); + expect(created.id).toBeTruthy(); + expect(created.slug).toBe("vertical-refactor"); + + const fetched = await caller.articleBySlug({ slug: "vertical-refactor" }); + expect(fetched?.id).toBe(created.id); + expect(fetched?.title).toBe("The Vertical Refactor"); + }); + + it("listArticles filters by status", async () => { + const caller = blogRouter.createCaller({}); + await caller.createArticle({ + title: "Draft One", + content: null, + authorId: "u1", + }); + const draftOnly = await caller.listArticles({ status: "draft" }); + expect(draftOnly).toHaveLength(1); + + const publishedOnly = await caller.listArticles({ status: "published" }); + expect(publishedOnly).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Verify the path alias works for tests/ folder** + +The test uses `@/` paths. Confirm `packages/blog/tsconfig.json` includes `"tests/**/*"` (it does per Task 2.1). + +- [ ] **Step 3: Run** + +Run: `cd packages/blog && pnpm vitest run tests/` +Expected: PASS — 2 tests. + +- [ ] **Step 4: Commit** + +```bash +git add packages/blog/tests +git commit -m "test(blog): add feature-level test exercising router → controller → use-case → repo" +``` + +--- + +### Task 2.17: Final verification + apps/cms boot smoke test + +- [ ] **Step 1: Run all blog tests** + +Run: `pnpm test --filter @repo/blog` +Expected: PASS — exact count: entities (7) + di container (2) + use-cases (2 + 2) + payload-articles repo (2) + controller (6) + router (3) + feature (2) = 26 tests. + +- [ ] **Step 2: Run repo-wide typecheck** + +Run: `pnpm typecheck` +Expected: PASS for all the packages we touched. Pre-existing failures in `@repo/api` and `@repo/ui` remain (will be addressed in Plans 5/6). + +- [ ] **Step 3: Boot apps/cms to verify articles collection registers** + +Ensure Postgres is running (`docker compose up -d postgres`). + +Run: `pnpm dev --filter @repo/cms` (in background; wait ~10s for "Ready"). + +Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin` +Expected: `200`. + +Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles` +Expected: `200`. + +Stop the dev server. + +> Note: When the dev server first connects, Payload may again offer to push schema changes (drop the OLD `users` and `media` columns since core-cms now only knows about `articles` — those collections come back in Plan 3). Answer N to the prompt or accept — for the smoke test we only care that admin responds 200, so the dev server can stay paused on the prompt. + +- [ ] **Step 4: Regenerate types one more time and confirm it includes Articles** + +Run: `cd apps/cms && pnpm generate:types` + +Run: `grep -c "Article" packages/core-cms/src/generated-types.ts` +Expected: at least 5 occurrences (Articles interface, references in Config, etc.). + +- [ ] **Step 5: Commit regenerated types** + +```bash +git add packages/core-cms/src/generated-types.ts +git commit -m "feat(core-cms): regenerate types — now includes Articles" +``` + +--- + +## Plan 2 Done Criteria + +- [ ] All 26 tests in `@repo/blog` pass +- [ ] `@repo/blog`, `@repo/core-cms`, `@repo/core-api` all typecheck +- [ ] `apps/cms` admin UI serves `/admin` and `/admin/collections/articles` with 200 +- [ ] `pnpm generate:types` in `apps/cms` produces a `generated-types.ts` containing `Articles` +- [ ] `core-cms/src/payload.config.ts` references `@repo/blog/cms` (not the old `cms-core`'s articles) +- [ ] `core-api/src/root.ts` references `@repo/blog/api` and exposes `blog.*` procedures +- [ ] `tsconfig.base.json` includes the three `@repo/blog*` aliases +- [ ] No deletions yet — `packages/core/src/entities/models/article.ts` and friends still exist (drained in Plan 6) + +**Next plan:** Plan 3 — Auth + Media features. Migrates the Users collection (auth feature) and Media collection (media feature). Restores `articles.author` to a `relationship → users` and adds back `featuredImage: upload → media`. Demonstrates that the per-feature DI pattern scales beyond one feature.