10 Commits

Author SHA1 Message Date
81fe4aa3b2 docs(plan-2): document constructor-injection pattern for payload repos
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>
2026-05-04 22:32:01 +02:00
a92341fb74 feat(core-cms): compose @repo/blog/cms into payload config 2026-05-04 22:29:50 +02:00
1d59698ebe feat(blog): add articles collection (simplified — no cross-feature refs) 2026-05-04 22:27:03 +02:00
f041ae5473 feat(blog): add articles controller with Zod validation 2026-05-04 22:25:10 +02:00
b250cdff80 feat(blog): add PayloadArticlesRepository with doc-to-entity mapping 2026-05-04 22:23:31 +02:00
1da0c06085 docs(plan-2): fix vitest.config.ts to include @/ alias
Vitest doesn't read tsconfig paths automatically; the alias must be
declared in vitest.config.ts via resolve.alias. Discovered during
execution and fixed in the live blog package; updating the source
plan so future feature plans (auth, media, etc.) start correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:22:05 +02:00
c00283506c feat(blog): add per-feature InversifyJS container (symbols, module, container) 2026-05-04 22:14:56 +02:00
41d7883e7a feat(blog): add MockArticlesRepository for tests 2026-05-04 22:14:14 +02:00
86228f2d3e feat(blog): add createArticleUseCase (test red until DI + mock repo exist) 2026-05-04 22:13:31 +02:00
b0dab254d1 feat(blog): add getArticlesUseCase (test red until DI + mock repo exist) 2026-05-04 22:13:00 +02:00
22 changed files with 775 additions and 11 deletions

View File

@@ -152,11 +152,21 @@ The domain `Article.content` field is also widened: the existing entity has `con
- [ ] **Step 4: Create vitest.config.ts** - [ ] **Step 4: Create vitest.config.ts**
```typescript ```typescript
import path from "node:path";
import { baseVitestConfig } from "@repo/typescript-config/vitest.base"; 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** - [ ] **Step 5: Create empty index.ts**
```typescript ```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. > 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** - [ ] **Step 4: Run — expect pass**
Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts`

View File

@@ -15,7 +15,6 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/core-cms": "workspace:*",
"@repo/core-shared": "workspace:*", "@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"inversify": "^6.2.0", "inversify": "^6.2.0",

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -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");
});
});

View File

@@ -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);
}

View 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);
});
});

View 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);

View 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,
);
});

View File

@@ -0,0 +1,3 @@
export const BLOG_SYMBOLS = {
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
} as const;

View File

@@ -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;
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}
}
}

View 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",
},
},
},
],
};

View File

@@ -0,0 +1 @@
export { articles } from "./collections/articles";

View File

@@ -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);
});
});
});

View File

@@ -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);
}

View File

@@ -1,3 +1,11 @@
import path from "path";
import { baseVitestConfig } from "@repo/typescript-config/vitest.base"; import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
export default baseVitestConfig; export default {
...baseVitestConfig,
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};

View File

@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/blog": "workspace:*",
"payload": "^3.14.0", "payload": "^3.14.0",
"@payloadcms/db-postgres": "^3.14.0", "@payloadcms/db-postgres": "^3.14.0",
"@payloadcms/richtext-lexical": "^3.14.0" "@payloadcms/richtext-lexical": "^3.14.0"

View File

@@ -67,6 +67,7 @@ export interface Config {
}; };
blocks: {}; blocks: {};
collections: { collections: {
articles: Article;
'payload-kv': PayloadKv; 'payload-kv': PayloadKv;
users: User; users: User;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
@@ -75,6 +76,7 @@ export interface Config {
}; };
collectionsJoins: {}; collectionsJoins: {};
collectionsSelect: { collectionsSelect: {
articles: ArticlesSelect<false> | ArticlesSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>; 'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -115,6 +117,42 @@ export interface UserAuthOperations {
password: string; 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv". * via the `definition` "payload-kv".
@@ -163,10 +201,15 @@ export interface User {
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: number; id: number;
document?: { document?:
| ({
relationTo: 'articles';
value: number | Article;
} | null)
| ({
relationTo: 'users'; relationTo: 'users';
value: number | User; value: number | User;
} | null; } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
relationTo: 'users'; relationTo: 'users';
@@ -209,6 +252,21 @@ export interface PayloadMigration {
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select". * via the `definition` "payload-kv_select".

View File

@@ -4,12 +4,14 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { articles } from "@repo/blog/cms";
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
export default buildConfig({ export default buildConfig({
editor: lexicalEditor(), editor: lexicalEditor(),
collections: [], collections: [articles],
globals: [], globals: [],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({ db: postgresAdapter({

6
pnpm-lock.yaml generated
View File

@@ -230,9 +230,6 @@ importers:
packages/blog: packages/blog:
dependencies: dependencies:
'@repo/core-cms':
specifier: workspace:*
version: link:../core-cms
'@repo/core-shared': '@repo/core-shared':
specifier: workspace:* specifier: workspace:*
version: link:../core-shared version: link:../core-shared
@@ -355,6 +352,9 @@ importers:
'@payloadcms/richtext-lexical': '@payloadcms/richtext-lexical':
specifier: ^3.14.0 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) 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: payload:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)