Revises Task 2.8's note: spec example used `import config from '@repo/core-cms'` which creates a workspace dependency cycle (blog deps on core-cms; core-cms deps on blog/cms). Constructor injection breaks the cycle at the package graph. Apply to all payload-backed feature repos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1843 lines
59 KiB
Markdown
1843 lines
59 KiB
Markdown
# 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 path from "node:path";
|
|
import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
|
|
|
|
export default {
|
|
...baseVitestConfig,
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
> Note: Vitest does NOT automatically read tsconfig `paths`. The `@/` alias used in `src/**/*.ts` source/test files must be declared explicitly in vitest.config.ts via `resolve.alias`. Same pattern repeats for every feature package's vitest.config.ts.
|
|
|
|
- [ ] **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<typeof articleSchema>;
|
|
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
|
|
```
|
|
|
|
- [ ] **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<Article | undefined>;
|
|
getArticleBySlug(slug: string): Promise<Article | undefined>;
|
|
getArticles(options?: {
|
|
status?: string;
|
|
authorId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Article[]>;
|
|
createArticle(input: Article): Promise<Article>;
|
|
updateArticle(
|
|
id: string,
|
|
input: Partial<Article>,
|
|
): Promise<Article | undefined>;
|
|
}
|
|
```
|
|
|
|
> 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<IArticlesRepository>(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<Article[]> {
|
|
const repo = blogContainer.get<IArticlesRepository>(
|
|
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<IArticlesRepository>(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<Article> {
|
|
const repo = blogContainer.get<IArticlesRepository>(
|
|
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<Article | undefined> {
|
|
return this._articles.find((a) => a.id === id);
|
|
}
|
|
|
|
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
|
return this._articles.find((a) => a.slug === slug);
|
|
}
|
|
|
|
async getArticles(options?: {
|
|
status?: string;
|
|
authorId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Article[]> {
|
|
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<Article> {
|
|
this._articles.push(input);
|
|
return input;
|
|
}
|
|
|
|
async updateArticle(
|
|
id: string,
|
|
input: Partial<Article>,
|
|
): Promise<Article | undefined> {
|
|
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<IArticlesRepository>(
|
|
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<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
|
.toConstantValue(custom);
|
|
const resolved = blogContainer.get<IArticlesRepository>(
|
|
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<IArticlesRepository>(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<typeof vi.fn>).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<typeof vi.fn>).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<Article | undefined> {
|
|
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<Article | undefined> {
|
|
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<Article[]> {
|
|
const payload = await getPayload({ config });
|
|
const where: Record<string, unknown> = {};
|
|
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<Article> {
|
|
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<Article>,
|
|
): Promise<Article | undefined> {
|
|
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.
|
|
|
|
> **Critical architectural note (revised after execution):** The repo takes `SanitizedConfig` via its constructor instead of `import config from '@repo/core-cms'` (which would create a workspace dependency cycle: blog ↔ core-cms). Constructor injection breaks the cycle at the package-graph level. The DI binding for production (Plan 5, app boot) supplies the config:
|
|
> ```ts
|
|
> import config from '@repo/core-cms'
|
|
> blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository)
|
|
> .toDynamicValue(() => new PayloadArticlesRepository(config))
|
|
> ```
|
|
> The blog `package.json` does NOT declare `@repo/core-cms` as a dependency. Apply this pattern to every payload-backed feature repository.
|
|
|
|
- [ ] **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<IArticlesRepository>(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<z.infer<typeof createInputSchema>>,
|
|
): Promise<Article> {
|
|
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<z.infer<typeof getInputSchema>>,
|
|
): Promise<Article[]> {
|
|
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<Article | undefined> {
|
|
const parsed = getBySlugInputSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
throw new InputParseError("Invalid get-article-by-slug input", {
|
|
cause: parsed.error,
|
|
});
|
|
}
|
|
const repo = blogContainer.get<IArticlesRepository>(
|
|
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<IArticlesRepository>(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<IArticlesRepository>(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.
|