AI Implementation feature(869): Admin Area Blog and Blog Page (#58)

This commit was merged in pull request #58.
This commit is contained in:
2026-07-23 10:17:55 +00:00
parent d468cca766
commit 294873240b
32 changed files with 1798 additions and 117 deletions
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import {
AdminBlogPost,
CreatePostPayload,
CreatePostSchema,
PostIdParamSchema,
UpdatePostPayload,
UpdatePostSchema,
} from './dto/blog.dto';
import { AdminBlogService } from './admin-blog.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/blog/posts')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminBlogController {
constructor(private readonly admin: AdminBlogService) {}
@Get()
@ApiOperation({ summary: 'List all blog posts (admin only, drafts included)' })
async list(): Promise<AdminBlogPost[]> {
return this.admin.listAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a single blog post by id (admin only)' })
async getOne(
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
): Promise<AdminBlogPost> {
return this.admin.getById(params.id);
}
@Post()
@ApiOperation({ summary: 'Create a new blog post (admin only)' })
async create(
@Body(new ZodValidationPipe(CreatePostSchema)) body: CreatePostPayload,
): Promise<AdminBlogPost> {
return this.admin.create(body);
}
@Put(':id')
@ApiOperation({ summary: 'Update a blog post (admin only)' })
async update(
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdatePostSchema)) body: UpdatePostPayload,
): Promise<AdminBlogPost> {
return this.admin.update(params.id, body);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a blog post (admin only)' })
async remove(
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
): Promise<void> {
await this.admin.remove(params.id);
}
}
@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { ApiError } from '../../common/errors/api-error';
import { AdminBlogPost, CreatePostPayload, UpdatePostPayload } from './dto/blog.dto';
@Injectable()
export class AdminBlogService {
constructor(
@InjectRepository(BlogPostEntity)
private readonly posts: Repository<BlogPostEntity>,
) {}
async listAll(): Promise<AdminBlogPost[]> {
const rows = await this.posts.find({ order: { updatedAt: 'DESC' } });
return rows.map((r) => this.toView(r));
}
async getById(id: string): Promise<AdminBlogPost> {
const row = await this.posts.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Post not found');
return this.toView(row);
}
async create(payload: CreatePostPayload): Promise<AdminBlogPost> {
const status = payload.status ?? 'draft';
const row = this.posts.create({
id: uuid(),
title: payload.title.trim(),
bodyMd: payload.bodyMd ?? '',
status,
publishedAt: status === 'published' ? new Date().toISOString() : null,
});
await this.posts.save(row);
return this.toView(row);
}
async update(id: string, payload: UpdatePostPayload): Promise<AdminBlogPost> {
const row = await this.posts.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Post not found');
if (payload.title !== undefined) row.title = payload.title.trim();
if (payload.bodyMd !== undefined) row.bodyMd = payload.bodyMd;
if (payload.status !== undefined) {
if (payload.status === 'published') {
row.status = 'published';
// Only stamp publishedAt if this post has never been published before;
// preserve the original publishedAt for re-publications.
if (!row.publishedAt) {
row.publishedAt = new Date().toISOString();
}
} else if (payload.status === 'draft') {
row.status = 'draft';
// preserve publishedAt so a later re-publish keeps the original publication timestamp.
}
}
await this.posts.save(row);
return this.toView(row);
}
async remove(id: string): Promise<void> {
const row = await this.posts.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Post not found');
await this.posts.delete({ id });
}
private toView(r: BlogPostEntity): AdminBlogPost {
return {
id: r.id,
title: r.title,
bodyMd: r.bodyMd ?? '',
status: r.status,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
publishedAt: r.publishedAt ?? null,
};
}
}
+5 -3
View File
@@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { BlogController } from './blog.controller';
import { BlogService } from './blog.service';
import { AdminBlogController } from './admin-blog.controller';
import { AdminBlogService } from './admin-blog.service';
@Module({
imports: [TypeOrmModule.forFeature([BlogPostEntity])],
controllers: [BlogController],
providers: [BlogService],
controllers: [BlogController, AdminBlogController],
providers: [BlogService, AdminBlogService],
})
export class BlogModule {}
export class BlogModule {}
+37 -1
View File
@@ -11,4 +11,40 @@ export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
export const PublicBlogListSchema = z.object({
posts: z.array(PublicBlogPostSchema),
});
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
export const AdminBlogPostSchema = z.object({
id: z.string(),
title: z.string(),
bodyMd: z.string(),
status: z.enum(['draft', 'published']),
createdAt: z.string(),
updatedAt: z.string(),
publishedAt: z.string().nullable(),
});
export type AdminBlogPost = z.infer<typeof AdminBlogPostSchema>;
export const CreatePostSchema = z.object({
title: z
.string()
.min(1, 'Title is required')
.max(200, 'Title must be at most 200 characters')
.refine((t) => t.trim().length > 0, { message: 'Title is required' }),
bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').default(''),
status: z.enum(['draft', 'published']).optional(),
});
export type CreatePostPayload = z.infer<typeof CreatePostSchema>;
export const UpdatePostSchema = z.object({
title: z
.string()
.min(1, 'Title is required')
.max(200, 'Title must be at most 200 characters')
.refine((t) => t.trim().length > 0, { message: 'Title is required' })
.optional(),
bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').optional(),
status: z.enum(['draft', 'published']).optional(),
});
export type UpdatePostPayload = z.infer<typeof UpdatePostSchema>;
export const PostIdParamSchema = z.object({ id: z.string().min(1).max(64) });