Compare commits
10 Commits
ab11f42e8d
...
81fe4aa3b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 81fe4aa3b2 | |||
| a92341fb74 | |||
| 1d59698ebe | |||
| f041ae5473 | |||
| b250cdff80 | |||
| 1da0c06085 | |||
| c00283506c | |||
| 41d7883e7a | |||
| 86228f2d3e | |||
| b0dab254d1 |
@@ -152,11 +152,21 @@ The domain `Article.content` field is also widened: the existing entity has `con
|
||||
- [ ] **Step 4: Create vitest.config.ts**
|
||||
|
||||
```typescript
|
||||
import path from "node:path";
|
||||
import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
|
||||
|
||||
export default baseVitestConfig;
|
||||
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
|
||||
@@ -1032,6 +1042,14 @@ export class PayloadArticlesRepository implements IArticlesRepository {
|
||||
|
||||
> 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`
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"inversify": "^6.2.0",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
}
|
||||
36
packages/blog/src/di/container.test.ts
Normal file
36
packages/blog/src/di/container.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
6
packages/blog/src/di/container.ts
Normal file
6
packages/blog/src/di/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { BlogModule } from "./module";
|
||||
|
||||
export const blogContainer = new Container({ defaultScope: "Singleton" });
|
||||
blogContainer.load(BlogModule);
|
||||
11
packages/blog/src/di/module.ts
Normal file
11
packages/blog/src/di/module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
3
packages/blog/src/di/symbols.ts
Normal file
3
packages/blog/src/di/symbols.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const BLOG_SYMBOLS = {
|
||||
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
|
||||
} as const;
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PayloadArticlesRepository } from "./payload-articles.repository";
|
||||
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("PayloadArticlesRepository", () => {
|
||||
const mockConfig = {} as never;
|
||||
|
||||
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(mockConfig);
|
||||
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(mockConfig);
|
||||
const result = await repo.getArticleBySlug("missing");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import "reflect-metadata";
|
||||
import { injectable } from "inversify";
|
||||
import { getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
|
||||
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 {
|
||||
private config: SanitizedConfig;
|
||||
|
||||
constructor(config: SanitizedConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async getArticle(id: string): Promise<Article | undefined> {
|
||||
const payload = await getPayload({ config: this.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: this.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: this.config });
|
||||
const where: Record<string, { equals: string }> = {};
|
||||
if (options?.status) where.status = { equals: options.status };
|
||||
if (options?.authorId) where.author = { equals: options.authorId };
|
||||
|
||||
const result = await payload.find({
|
||||
collection: "articles",
|
||||
where: where as never,
|
||||
limit: options?.limit ?? 50,
|
||||
page: options?.offset
|
||||
? Math.floor(options.offset / (options.limit ?? 50)) + 1
|
||||
: 1,
|
||||
overrideAccess: false,
|
||||
});
|
||||
return result.docs.map((d) => mapDoc(d as PayloadArticleDoc));
|
||||
}
|
||||
|
||||
async createArticle(input: Article): Promise<Article> {
|
||||
const payload = await getPayload({ config: this.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: this.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
72
packages/blog/src/integrations/cms/collections/articles.ts
Normal file
72
packages/blog/src/integrations/cms/collections/articles.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
1
packages/blog/src/integrations/cms/index.ts
Normal file
1
packages/blog/src/integrations/cms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { articles } from "./collections/articles";
|
||||
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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().optional(),
|
||||
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({
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content ?? null,
|
||||
authorId: parsed.data.authorId,
|
||||
slug: parsed.data.slug,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
import path from "path";
|
||||
import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
|
||||
|
||||
export default baseVitestConfig;
|
||||
export default {
|
||||
...baseVitestConfig,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/blog": "workspace:*",
|
||||
"payload": "^3.14.0",
|
||||
"@payloadcms/db-postgres": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0"
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface Config {
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
articles: Article;
|
||||
'payload-kv': PayloadKv;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
@@ -75,6 +76,7 @@ export interface Config {
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
articles: ArticlesSelect<false> | ArticlesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
@@ -115,6 +117,42 @@ export interface UserAuthOperations {
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "articles".
|
||||
*/
|
||||
export interface Article {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Auto-generated from title if left empty
|
||||
*/
|
||||
slug?: string | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
status: 'draft' | 'published';
|
||||
/**
|
||||
* Temporary text field; restored to users relationship in Plan 3.
|
||||
*/
|
||||
author: string;
|
||||
publishedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
@@ -163,10 +201,15 @@ export interface User {
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?: {
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'articles';
|
||||
value: number | Article;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
} | null;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
@@ -209,6 +252,21 @@ export interface PayloadMigration {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "articles_select".
|
||||
*/
|
||||
export interface ArticlesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
content?: T;
|
||||
status?: T;
|
||||
author?: T;
|
||||
publishedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
|
||||
@@ -4,12 +4,14 @@ 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: [],
|
||||
collections: [articles],
|
||||
globals: [],
|
||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||
db: postgresAdapter({
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -230,9 +230,6 @@ importers:
|
||||
|
||||
packages/blog:
|
||||
dependencies:
|
||||
'@repo/core-cms':
|
||||
specifier: workspace:*
|
||||
version: link:../core-cms
|
||||
'@repo/core-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../core-shared
|
||||
@@ -355,6 +352,9 @@ importers:
|
||||
'@payloadcms/richtext-lexical':
|
||||
specifier: ^3.14.0
|
||||
version: 3.81.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.81.0(graphql@16.13.2)(next@16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.81.0(graphql@16.13.2)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(next@16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(payload@3.81.0(graphql@16.13.2)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(yjs@13.6.30)
|
||||
'@repo/blog':
|
||||
specifier: workspace:*
|
||||
version: link:../blog
|
||||
payload:
|
||||
specifier: ^3.14.0
|
||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||
|
||||
Reference in New Issue
Block a user