feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

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

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

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

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

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

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

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

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