feat(ADM-018): completed feature
This commit is contained in:
135
project/src/modules/cms/api/cms.routes.ts
Normal file
135
project/src/modules/cms/api/cms.routes.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { CmsService } from '../application/cms-service.js';
|
||||
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
|
||||
import type { Page } from '../domain/page.js';
|
||||
import { PgCmsRepository } from '../infrastructure/pg-cms-repository.js';
|
||||
|
||||
export interface CmsRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const createSchema = z
|
||||
.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'slug must be kebab-case'),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(50000),
|
||||
})
|
||||
.strip();
|
||||
|
||||
const updateSchema = z
|
||||
.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
.optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
body: z.string().min(1).max(50000).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
const idParamSchema = z.object({ id: z.uuid() });
|
||||
const slugParamSchema = z.object({ slug: z.string().min(1).max(160) });
|
||||
|
||||
export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDeps): Promise<void> {
|
||||
const service = new CmsService(new PgCmsRepository(deps.pool));
|
||||
|
||||
app.get('/cms/pages', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const pages = await service.listAll();
|
||||
return reply.send({ items: pages.map(serialize) });
|
||||
});
|
||||
|
||||
app.post('/cms/pages', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(createSchema, request.body);
|
||||
try {
|
||||
const page = await service.create(input);
|
||||
return reply.code(201).send(serialize(page));
|
||||
} catch (error) {
|
||||
throw mapCmsError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/cms/pages/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
const patch = parseJson(updateSchema, request.body);
|
||||
try {
|
||||
const page = await service.update(id, patch);
|
||||
return reply.send(serialize(page));
|
||||
} catch (error) {
|
||||
throw mapCmsError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/cms/pages/:id/publish', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
try {
|
||||
const page = await service.publish(id);
|
||||
return reply.send(serialize(page));
|
||||
} catch (error) {
|
||||
throw mapCmsError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/cms/pages/:id/unpublish', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
try {
|
||||
const page = await service.unpublish(id);
|
||||
return reply.send(serialize(page));
|
||||
} catch (error) {
|
||||
throw mapCmsError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/cms/pages/:slug', async (request, reply) => {
|
||||
const { slug } = slugParamSchema.parse(request.params);
|
||||
try {
|
||||
const page = await service.getPublicPageBySlug(slug);
|
||||
return reply.send(serialize(page));
|
||||
} catch (error) {
|
||||
throw mapCmsError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mapCmsError(error: unknown): Error {
|
||||
if (error instanceof DuplicateSlugError)
|
||||
return new AppError(409, 'CMS_DUPLICATE_SLUG', error.message);
|
||||
if (error instanceof PageNotPublishedError)
|
||||
return new AppError(404, 'CMS_NOT_FOUND', error.message);
|
||||
if (error instanceof PageNotFoundError) return new AppError(404, 'CMS_NOT_FOUND', error.message);
|
||||
return error instanceof Error ? error : new Error('Unknown cms error');
|
||||
}
|
||||
|
||||
function serialize(page: Page) {
|
||||
return {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
body: page.body,
|
||||
status: page.status,
|
||||
createdAt: page.createdAt.toISOString(),
|
||||
updatedAt: page.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
42
project/src/modules/cms/application/cms-service.ts
Normal file
42
project/src/modules/cms/application/cms-service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
|
||||
import type { CmsRepository } from '../domain/ports.js';
|
||||
import type { CreatePageInput, Page, UpdatePageInput } from '../domain/page.js';
|
||||
|
||||
export class CmsService {
|
||||
constructor(private readonly repo: CmsRepository) {}
|
||||
|
||||
async create(input: CreatePageInput): Promise<Page> {
|
||||
const created = await this.repo.create(input);
|
||||
if (!created) throw new DuplicateSlugError();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, patch: UpdatePageInput): Promise<Page> {
|
||||
const updated = await this.repo.update(id, patch);
|
||||
if (!updated) throw new PageNotFoundError();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async publish(id: string): Promise<Page> {
|
||||
const updated = await this.repo.setStatus(id, 'published');
|
||||
if (!updated) throw new PageNotFoundError();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async unpublish(id: string): Promise<Page> {
|
||||
const updated = await this.repo.setStatus(id, 'draft');
|
||||
if (!updated) throw new PageNotFoundError();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getPublicPageBySlug(slug: string): Promise<Page> {
|
||||
const page = await this.repo.findBySlug(slug);
|
||||
if (!page) throw new PageNotFoundError();
|
||||
if (page.status !== 'published') throw new PageNotPublishedError();
|
||||
return page;
|
||||
}
|
||||
|
||||
async listAll(): Promise<Page[]> {
|
||||
return this.repo.listAll();
|
||||
}
|
||||
}
|
||||
20
project/src/modules/cms/domain/errors.ts
Normal file
20
project/src/modules/cms/domain/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class DuplicateSlugError extends Error {
|
||||
constructor() {
|
||||
super('A page with this slug already exists');
|
||||
this.name = 'DuplicateSlugError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PageNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Page not found');
|
||||
this.name = 'PageNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PageNotPublishedError extends Error {
|
||||
constructor() {
|
||||
super('Page is not published');
|
||||
this.name = 'PageNotPublishedError';
|
||||
}
|
||||
}
|
||||
23
project/src/modules/cms/domain/page.ts
Normal file
23
project/src/modules/cms/domain/page.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type PageStatus = 'draft' | 'published';
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: PageStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreatePageInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface UpdatePageInput {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
}
|
||||
10
project/src/modules/cms/domain/ports.ts
Normal file
10
project/src/modules/cms/domain/ports.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CreatePageInput, Page, UpdatePageInput } from './page.js';
|
||||
|
||||
export interface CmsRepository {
|
||||
create(input: CreatePageInput): Promise<Page | undefined>;
|
||||
update(id: string, patch: UpdatePageInput): Promise<Page | undefined>;
|
||||
setStatus(id: string, status: 'draft' | 'published'): Promise<Page | undefined>;
|
||||
findBySlug(slug: string): Promise<Page | undefined>;
|
||||
findById(id: string): Promise<Page | undefined>;
|
||||
listAll(): Promise<Page[]>;
|
||||
}
|
||||
7
project/src/modules/cms/index.ts
Normal file
7
project/src/modules/cms/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Public API of the CMS module. */
|
||||
export { registerCmsRoutes, type CmsRoutesDeps } from './api/cms.routes.js';
|
||||
export { CmsService } from './application/cms-service.js';
|
||||
export { PgCmsRepository } from './infrastructure/pg-cms-repository.js';
|
||||
export { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from './domain/errors.js';
|
||||
export type { CmsRepository } from './domain/ports.js';
|
||||
export type { Page, PageStatus, CreatePageInput, UpdatePageInput } from './domain/page.js';
|
||||
96
project/src/modules/cms/infrastructure/pg-cms-repository.ts
Normal file
96
project/src/modules/cms/infrastructure/pg-cms-repository.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type pg from 'pg';
|
||||
import type { CmsRepository } from '../domain/ports.js';
|
||||
import type { CreatePageInput, Page, PageStatus, UpdatePageInput } from '../domain/page.js';
|
||||
|
||||
interface PageRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: PageStatus;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class PgCmsRepository implements CmsRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async create(input: CreatePageInput): Promise<Page | undefined> {
|
||||
const result = await this.pool.query<PageRow>(
|
||||
`INSERT INTO cms_pages (slug, title, body)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
RETURNING *`,
|
||||
[input.slug, input.title, input.body],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toPage(row) : undefined;
|
||||
}
|
||||
|
||||
async update(id: string, patch: UpdatePageInput): Promise<Page | undefined> {
|
||||
const set: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (patch.slug !== undefined) {
|
||||
values.push(patch.slug);
|
||||
set.push(`slug = $${values.length}`);
|
||||
}
|
||||
if (patch.title !== undefined) {
|
||||
values.push(patch.title);
|
||||
set.push(`title = $${values.length}`);
|
||||
}
|
||||
if (patch.body !== undefined) {
|
||||
values.push(patch.body);
|
||||
set.push(`body = $${values.length}`);
|
||||
}
|
||||
if (set.length === 0) return this.findById(id);
|
||||
values.push(id);
|
||||
const result = await this.pool.query<PageRow>(
|
||||
`UPDATE cms_pages SET ${set.join(', ')}, updated_at = now() WHERE id = $${values.length} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toPage(row) : undefined;
|
||||
}
|
||||
|
||||
async setStatus(id: string, status: PageStatus): Promise<Page | undefined> {
|
||||
const result = await this.pool.query<PageRow>(
|
||||
`UPDATE cms_pages SET status = $2, updated_at = now() WHERE id = $1 RETURNING *`,
|
||||
[id, status],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toPage(row) : undefined;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<Page | undefined> {
|
||||
const result = await this.pool.query<PageRow>('SELECT * FROM cms_pages WHERE slug = $1', [
|
||||
slug,
|
||||
]);
|
||||
const row = result.rows[0];
|
||||
return row ? toPage(row) : undefined;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Page | undefined> {
|
||||
const result = await this.pool.query<PageRow>('SELECT * FROM cms_pages WHERE id = $1', [id]);
|
||||
const row = result.rows[0];
|
||||
return row ? toPage(row) : undefined;
|
||||
}
|
||||
|
||||
async listAll(): Promise<Page[]> {
|
||||
const result = await this.pool.query<PageRow>(
|
||||
'SELECT * FROM cms_pages ORDER BY created_at DESC',
|
||||
);
|
||||
return result.rows.map(toPage);
|
||||
}
|
||||
}
|
||||
|
||||
function toPage(row: PageRow): Page {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
63
project/src/modules/cms/tests/cms-service.test.ts
Normal file
63
project/src/modules/cms/tests/cms-service.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CmsService } from '../application/cms-service.js';
|
||||
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
|
||||
import type { CmsRepository } from '../domain/ports.js';
|
||||
import type { Page } from '../domain/page.js';
|
||||
|
||||
const PAGE: Page = {
|
||||
id: 'page-1',
|
||||
slug: 'about',
|
||||
title: 'About',
|
||||
body: 'Content',
|
||||
status: 'draft',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function repo(overrides: Partial<CmsRepository> = {}): CmsRepository {
|
||||
return {
|
||||
create: async () => PAGE,
|
||||
update: async () => PAGE,
|
||||
setStatus: async (id, status) => ({ ...PAGE, id, status }),
|
||||
findBySlug: async () => PAGE,
|
||||
findById: async () => PAGE,
|
||||
listAll: async () => [PAGE],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CmsService', () => {
|
||||
it('creates a page (200), duplicate slug -> 409', async () => {
|
||||
const service = new CmsService(repo());
|
||||
const page = await service.create({ slug: 'about', title: 'About', body: 'Content' });
|
||||
expect(page.id).toBe('page-1');
|
||||
const dupService = new CmsService(repo({ create: async () => undefined }));
|
||||
await expect(
|
||||
dupService.create({ slug: 'about', title: 'About', body: 'Content' }),
|
||||
).rejects.toBeInstanceOf(DuplicateSlugError);
|
||||
});
|
||||
|
||||
it('returns 200 on published page and 404 on draft', async () => {
|
||||
const published = new CmsService(
|
||||
repo({ findBySlug: async () => ({ ...PAGE, status: 'published' }) }),
|
||||
);
|
||||
const draft = new CmsService(repo());
|
||||
const page = await published.getPublicPageBySlug('about');
|
||||
expect(page.status).toBe('published');
|
||||
await expect(published.getPublicPageBySlug('about')).resolves.toMatchObject({
|
||||
status: 'published',
|
||||
});
|
||||
await expect(draft.getPublicPageBySlug('about')).rejects.toBeInstanceOf(PageNotPublishedError);
|
||||
});
|
||||
|
||||
it('returns 404 when page does not exist', async () => {
|
||||
const service = new CmsService(repo({ findBySlug: async () => undefined }));
|
||||
await expect(service.getPublicPageBySlug('missing')).rejects.toBeInstanceOf(PageNotFoundError);
|
||||
});
|
||||
|
||||
it('publish/unpublish change status', async () => {
|
||||
const service = new CmsService(repo());
|
||||
expect((await service.publish('page-1')).status).toBe('published');
|
||||
expect((await service.unpublish('page-1')).status).toBe('draft');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user