FullStack-Blog-Nestjs-Nextjs-Postgres
This commit is contained in:
145
backend/src/blog-posts/blog-posts.controller.ts
Normal file
145
backend/src/blog-posts/blog-posts.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BlogPostsService } from './blog-posts.service';
|
||||
import { CreatePostDto } from './dto/create-post.dto';
|
||||
import { UpdatePostDto } from './dto/update-post.dto';
|
||||
import { ListPostsQueryDto } from './dto/list-posts-query.dto';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { UserRole } from '../users/entities/user.entity';
|
||||
|
||||
@Controller('blog-posts')
|
||||
export class BlogPostsController {
|
||||
constructor(private readonly blogPostsService: BlogPostsService) {}
|
||||
|
||||
// ─── Public endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
@Get('public')
|
||||
@Public()
|
||||
async findPublished(@Query() query: ListPostsQueryDto) {
|
||||
return this.blogPostsService.findPublished(query);
|
||||
}
|
||||
|
||||
@Get('public/featured')
|
||||
@Public()
|
||||
async findFeatured() {
|
||||
return this.blogPostsService.findFeatured();
|
||||
}
|
||||
|
||||
@Get('public/:slug')
|
||||
@Public()
|
||||
async findBySlug(@Param('slug') slug: string) {
|
||||
const post = await this.blogPostsService.findBySlug(slug);
|
||||
return { success: true, post };
|
||||
}
|
||||
|
||||
@Post('public/:slug/view')
|
||||
@Public()
|
||||
async incrementViews(@Param('slug') slug: string) {
|
||||
await this.blogPostsService.incrementViews(slug);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── HTMX partials (public) ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders the post-grid partial.
|
||||
* Templates expect:
|
||||
* result → { items, page, pageSize, total, totalPages }
|
||||
* query → the raw query params (q, tags, category, sort, page, pageSize)
|
||||
*/
|
||||
@Get('partials/grid')
|
||||
@Public()
|
||||
async gridPartial(@Query() query: ListPostsQueryDto, @Res() res: Response) {
|
||||
const result = await this.blogPostsService.findPublished(query);
|
||||
return res.render('partials/post-grid', {
|
||||
result,
|
||||
query: {
|
||||
q: query.q || '',
|
||||
tags: query.tags || '',
|
||||
category: query.category || '',
|
||||
sort: query.sort || 'newest',
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── HTMX partials (authenticated) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders the dashboard-post-table partial.
|
||||
* Template expects: posts[], currentUser
|
||||
*/
|
||||
@Get('partials/table')
|
||||
async tablePartial(
|
||||
@Query() query: ListPostsQueryDto,
|
||||
@CurrentUser() currentUser: any,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const data = await this.blogPostsService.findAll(query, currentUser);
|
||||
return res.render('partials/dashboard-post-table', {
|
||||
posts: data.posts,
|
||||
currentUser,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Authenticated CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() query: ListPostsQueryDto, @CurrentUser() user: any) {
|
||||
return this.blogPostsService.findAll(query, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(UserRole.ADMIN, UserRole.MANAGER)
|
||||
async create(@Body() dto: CreatePostDto, @CurrentUser() user: any, @Res() res: Response, @Req() req: Request) {
|
||||
const post = await this.blogPostsService.create(dto, user);
|
||||
const isHtmx = req.headers['hx-request'] === 'true';
|
||||
if (isHtmx) {
|
||||
return res.render('partials/flash', { type: 'success', message: `Post "${post.title}" created!` });
|
||||
}
|
||||
return res.json({ success: true, post });
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePostDto,
|
||||
@CurrentUser() user: any,
|
||||
@Res() res: Response,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const post = await this.blogPostsService.update(id, dto, user);
|
||||
const isHtmx = req.headers['hx-request'] === 'true';
|
||||
if (isHtmx) {
|
||||
return res.render('partials/flash', { type: 'success', message: `Post "${post.title}" updated!` });
|
||||
}
|
||||
return res.json({ success: true, post });
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async remove(@Param('id') id: string, @Res() res: Response, @Req() req: Request) {
|
||||
await this.blogPostsService.remove(id);
|
||||
const isHtmx = req.headers['hx-request'] === 'true';
|
||||
if (isHtmx) {
|
||||
return res.render('partials/flash', { type: 'success', message: 'Post deleted.' });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
}
|
||||
}
|
||||
14
backend/src/blog-posts/blog-posts.module.ts
Normal file
14
backend/src/blog-posts/blog-posts.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogPost } from './entities/blog-post.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { BlogPostsController } from './blog-posts.controller';
|
||||
import { BlogPostsService } from './blog-posts.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPost, User])],
|
||||
controllers: [BlogPostsController],
|
||||
providers: [BlogPostsService],
|
||||
exports: [BlogPostsService],
|
||||
})
|
||||
export class BlogPostsModule {}
|
||||
150
backend/src/blog-posts/blog-posts.service.spec.ts
Normal file
150
backend/src/blog-posts/blog-posts.service.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BlogPostsService } from './blog-posts.service';
|
||||
import { BlogPost, PostStatus, ContentFormat } from './entities/blog-post.entity';
|
||||
import { UserRole } from '../users/entities/user.entity';
|
||||
|
||||
const mockPost: Partial<BlogPost> = {
|
||||
id: 'post-uuid-1',
|
||||
title: 'Test Post',
|
||||
slug: 'test-post-abc',
|
||||
status: PostStatus.PUBLISHED,
|
||||
excerpt: 'A test post',
|
||||
content: '# Test',
|
||||
contentFormat: ContentFormat.MARKDOWN,
|
||||
authorId: 'user-1',
|
||||
isFeatured: false,
|
||||
views: 100,
|
||||
tags: ['test'],
|
||||
categories: ['Testing'],
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(1),
|
||||
getMany: jest.fn().mockResolvedValue([mockPost]),
|
||||
getOne: jest.fn().mockResolvedValue(mockPost),
|
||||
};
|
||||
|
||||
const mockRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
increment: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
describe('BlogPostsService', () => {
|
||||
let service: BlogPostsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BlogPostsService,
|
||||
{ provide: getRepositoryToken(BlogPost), useValue: mockRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BlogPostsService>(BlogPostsService);
|
||||
jest.clearAllMocks();
|
||||
mockRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
});
|
||||
|
||||
describe('findPublished', () => {
|
||||
it('should return paginated published posts', async () => {
|
||||
const result = await service.findPublished({});
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
|
||||
'post.status = :status',
|
||||
{ status: PostStatus.PUBLISHED },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBySlug', () => {
|
||||
it('should return published post by slug', async () => {
|
||||
mockQueryBuilder.getOne.mockResolvedValue(mockPost);
|
||||
const post = await service.findBySlug('test-post-abc');
|
||||
expect(post.slug).toBe('test-post-abc');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for draft post', async () => {
|
||||
mockQueryBuilder.getOne.mockResolvedValue({ ...mockPost, status: PostStatus.DRAFT });
|
||||
await expect(service.findBySlug('test-post-abc')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent slug', async () => {
|
||||
mockQueryBuilder.getOne.mockResolvedValue(null);
|
||||
await expect(service.findBySlug('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const adminUser = { sub: 'user-1', role: UserRole.ADMIN };
|
||||
const managerUser = { sub: 'user-2', role: UserRole.MANAGER };
|
||||
|
||||
it('ADMIN can create published post', async () => {
|
||||
mockRepo.create.mockReturnValue({ ...mockPost });
|
||||
mockRepo.save.mockResolvedValue({ ...mockPost, status: PostStatus.PUBLISHED });
|
||||
|
||||
const result = await service.create(
|
||||
{ title: 'New Post', content: 'Content', status: PostStatus.PUBLISHED },
|
||||
adminUser,
|
||||
);
|
||||
expect(mockRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MANAGER is forced to draft', async () => {
|
||||
mockRepo.create.mockImplementation((data) => ({ ...data }));
|
||||
mockRepo.save.mockImplementation((data) => Promise.resolve(data));
|
||||
|
||||
const result = await service.create(
|
||||
{ title: 'Manager Post', content: 'Content', status: PostStatus.PUBLISHED },
|
||||
managerUser,
|
||||
);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: PostStatus.DRAFT }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementViews', () => {
|
||||
it('should increment post views', async () => {
|
||||
mockRepo.increment.mockResolvedValue({});
|
||||
await service.incrementViews('test-post-abc');
|
||||
expect(mockRepo.increment).toHaveBeenCalledWith(
|
||||
{ slug: 'test-post-abc' },
|
||||
'views',
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should remove a post', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(mockPost);
|
||||
mockRepo.remove.mockResolvedValue({});
|
||||
await service.remove('post-uuid-1');
|
||||
expect(mockRepo.remove).toHaveBeenCalledWith(mockPost);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent post', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.remove('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
284
backend/src/blog-posts/blog-posts.service.ts
Normal file
284
backend/src/blog-posts/blog-posts.service.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { BlogPost, PostStatus } from './entities/blog-post.entity';
|
||||
import { UserRole } from '../users/entities/user.entity';
|
||||
import { CreatePostDto } from './dto/create-post.dto';
|
||||
import { UpdatePostDto } from './dto/update-post.dto';
|
||||
import { ListPostsQueryDto } from './dto/list-posts-query.dto';
|
||||
import { generateSlug } from '../common/helpers/slug.helper';
|
||||
|
||||
// Safe author columns — never include passwordHash
|
||||
const AUTHOR_COLS: string[] = [
|
||||
'author.id',
|
||||
'author.email',
|
||||
'author.name',
|
||||
'author.role',
|
||||
'author.isActive',
|
||||
'author.createdAt',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BlogPostsService {
|
||||
constructor(
|
||||
@InjectRepository(BlogPost)
|
||||
private readonly postRepo: Repository<BlogPost>,
|
||||
) {}
|
||||
|
||||
// ─── Public (published) posts ──────────────────────────────────────────────
|
||||
|
||||
async findPublished(query: ListPostsQueryDto) {
|
||||
const page = parseInt(query.page || '1', 10);
|
||||
const pageSize = parseInt(query.pageSize || '9', 10);
|
||||
|
||||
const qb = this.postRepo
|
||||
.createQueryBuilder('post')
|
||||
.leftJoin('post.author', 'author')
|
||||
.addSelect(AUTHOR_COLS)
|
||||
.where('post.status = :status', { status: PostStatus.PUBLISHED });
|
||||
|
||||
if (query.q) {
|
||||
qb.andWhere(
|
||||
'(post.title ILIKE :q OR post.excerpt ILIKE :q)',
|
||||
{ q: `%${query.q}%` },
|
||||
);
|
||||
}
|
||||
|
||||
// Support comma-separated tags (from HTMX filter form: name="tags")
|
||||
if (query.tags) {
|
||||
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
tagList.forEach((tag, i) => {
|
||||
qb.andWhere(`post.tags LIKE :tag${i}`, { [`tag${i}`]: `%${tag}%` });
|
||||
});
|
||||
}
|
||||
|
||||
if (query.category) {
|
||||
qb.andWhere('post.categories LIKE :cat', { cat: `%${query.category}%` });
|
||||
}
|
||||
|
||||
// Sort options matching the home page template values
|
||||
switch (query.sort) {
|
||||
case 'oldest':
|
||||
qb.orderBy('post.createdAt', 'ASC');
|
||||
break;
|
||||
case 'most_viewed':
|
||||
qb.orderBy('post.views', 'DESC');
|
||||
break;
|
||||
case 'featured':
|
||||
qb.orderBy('post.isFeatured', 'DESC').addOrderBy('post.createdAt', 'DESC');
|
||||
break;
|
||||
default: // 'newest' or undefined
|
||||
qb.orderBy('post.createdAt', 'DESC');
|
||||
}
|
||||
|
||||
const total = await qb.getCount();
|
||||
const posts = await qb.skip((page - 1) * pageSize).take(pageSize).getMany();
|
||||
|
||||
return {
|
||||
items: posts,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async findFeatured(limit = 3) {
|
||||
return this.postRepo
|
||||
.createQueryBuilder('post')
|
||||
.leftJoin('post.author', 'author')
|
||||
.addSelect(AUTHOR_COLS)
|
||||
.where('post.status = :status', { status: PostStatus.PUBLISHED })
|
||||
.andWhere('post.isFeatured = true')
|
||||
.orderBy('post.createdAt', 'DESC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findPopular(limit = 5) {
|
||||
return this.postRepo
|
||||
.createQueryBuilder('post')
|
||||
.where('post.status = :status', { status: PostStatus.PUBLISHED })
|
||||
.orderBy('post.views', 'DESC')
|
||||
.take(limit)
|
||||
.select(['post.slug', 'post.title', 'post.views'])
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findRelated(post: BlogPost, limit = 4) {
|
||||
const qb = this.postRepo
|
||||
.createQueryBuilder('p')
|
||||
.where('p.status = :status', { status: PostStatus.PUBLISHED })
|
||||
.andWhere('p.id != :id', { id: post.id })
|
||||
.orderBy('p.createdAt', 'DESC')
|
||||
.take(limit);
|
||||
|
||||
// Prefer posts sharing at least one category or tag
|
||||
const cats = (post.categories || []).filter(Boolean);
|
||||
const tags = (post.tags || []).filter(Boolean);
|
||||
|
||||
if (cats.length > 0) {
|
||||
qb.andWhere(
|
||||
'(' + cats.map((_, i) => `p.categories LIKE :rc${i}`).join(' OR ') + ')',
|
||||
Object.fromEntries(cats.map((c, i) => [`rc${i}`, `%${c}%`])),
|
||||
);
|
||||
} else if (tags.length > 0) {
|
||||
qb.andWhere(
|
||||
'(' + tags.map((_, i) => `p.tags LIKE :rt${i}`).join(' OR ') + ')',
|
||||
Object.fromEntries(tags.map((t, i) => [`rt${i}`, `%${t}%`])),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await qb.getMany();
|
||||
// Fallback: if nothing related, return latest posts
|
||||
if (results.length === 0) {
|
||||
return this.postRepo
|
||||
.createQueryBuilder('p')
|
||||
.where('p.status = :status', { status: PostStatus.PUBLISHED })
|
||||
.andWhere('p.id != :id', { id: post.id })
|
||||
.orderBy('p.createdAt', 'DESC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<BlogPost> {
|
||||
const post = await this.postRepo
|
||||
.createQueryBuilder('post')
|
||||
.leftJoin('post.author', 'author')
|
||||
.addSelect(AUTHOR_COLS)
|
||||
.where('post.slug = :slug', { slug })
|
||||
.getOne();
|
||||
|
||||
if (!post || post.status !== PostStatus.PUBLISHED) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
async incrementViews(slug: string) {
|
||||
await this.postRepo.increment({ slug }, 'views', 1);
|
||||
}
|
||||
|
||||
// ─── Admin / authenticated ──────────────────────────────────────────────────
|
||||
|
||||
async findAll(query: ListPostsQueryDto, user: any) {
|
||||
const page = parseInt(query.page || '1', 10);
|
||||
const pageSize = parseInt(query.pageSize || '20', 10);
|
||||
const qb = this.postRepo
|
||||
.createQueryBuilder('post')
|
||||
.leftJoin('post.author', 'author')
|
||||
.addSelect(AUTHOR_COLS);
|
||||
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
qb.where('post.authorId = :uid', { uid: user.sub });
|
||||
}
|
||||
if (query.status) {
|
||||
qb.andWhere('post.status = :status', { status: query.status });
|
||||
}
|
||||
if (query.q) {
|
||||
qb.andWhere('post.title ILIKE :q', { q: `%${query.q}%` });
|
||||
}
|
||||
|
||||
qb.orderBy('post.createdAt', 'DESC');
|
||||
const total = await qb.getCount();
|
||||
const posts = await qb.skip((page - 1) * pageSize).take(pageSize).getMany();
|
||||
|
||||
return { posts, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
async create(dto: CreatePostDto, user: any): Promise<BlogPost> {
|
||||
let status = dto.status || PostStatus.DRAFT;
|
||||
// MANAGER can only create drafts
|
||||
if (user.role === UserRole.MANAGER) {
|
||||
status = PostStatus.DRAFT;
|
||||
}
|
||||
|
||||
// Use provided slug or generate from title
|
||||
const slug = dto.slug ? dto.slug.trim() : generateSlug(dto.title);
|
||||
|
||||
const post = this.postRepo.create({
|
||||
id: uuidv4(),
|
||||
title: dto.title,
|
||||
slug,
|
||||
status,
|
||||
excerpt: dto.excerpt || '',
|
||||
content: dto.content,
|
||||
contentFormat: dto.contentFormat,
|
||||
authorId: user.sub,
|
||||
featuredImageUrl: dto.featuredImageUrl,
|
||||
featuredImageAlt: dto.featuredImageAlt,
|
||||
isFeatured: dto.isFeatured || false,
|
||||
tags: dto.tags || [],
|
||||
categories: dto.categories || [],
|
||||
});
|
||||
|
||||
return this.postRepo.save(post);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePostDto, user: any): Promise<BlogPost> {
|
||||
const post = await this.postRepo.findOne({ where: { id } });
|
||||
if (!post) throw new NotFoundException('Post not found');
|
||||
|
||||
// Regenerate slug if title changed and no explicit slug provided
|
||||
if (dto.title && dto.title !== post.title && !dto.slug) {
|
||||
(dto as any).slug = generateSlug(dto.title);
|
||||
}
|
||||
|
||||
Object.assign(post, dto);
|
||||
return this.postRepo.save(post);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const post = await this.postRepo.findOne({ where: { id } });
|
||||
if (!post) throw new NotFoundException('Post not found');
|
||||
await this.postRepo.remove(post);
|
||||
}
|
||||
|
||||
// ─── Tag cloud ──────────────────────────────────────────────────────────────
|
||||
|
||||
async getTagCloud(): Promise<Array<{ name: string; count: number }>> {
|
||||
const posts = await this.postRepo.find({
|
||||
where: { status: PostStatus.PUBLISHED },
|
||||
select: ['tags'],
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags || []) {
|
||||
const t = tag.trim();
|
||||
if (t) counts[t] = (counts[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(counts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
// ─── Category cloud ─────────────────────────────────────────────────────────
|
||||
|
||||
async getCategoryCloud(): Promise<Array<{ name: string; count: number }>> {
|
||||
const posts = await this.postRepo.find({
|
||||
where: { status: PostStatus.PUBLISHED },
|
||||
select: ['categories'],
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const post of posts) {
|
||||
for (const cat of post.categories || []) {
|
||||
const c = cat.trim();
|
||||
if (c) counts[c] = (counts[c] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(counts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
}
|
||||
78
backend/src/blog-posts/dto/create-post.dto.ts
Normal file
78
backend/src/blog-posts/dto/create-post.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ContentFormat, PostStatus } from '../entities/blog-post.entity';
|
||||
|
||||
/** Parse "true"/"false" strings into a real boolean */
|
||||
function transformBoolean({ value }: { value: unknown }): boolean | undefined {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Parse a comma-separated string OR an array into a trimmed string[] */
|
||||
function transformCSV({ value }: { value: unknown }): string[] {
|
||||
if (Array.isArray(value)) return (value as unknown[]).map((v) => String(v).trim()).filter(Boolean);
|
||||
if (typeof value === 'string') return value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export class CreatePostDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title: string;
|
||||
|
||||
/** Optional custom slug; if omitted the service generates one from the title */
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(300)
|
||||
slug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
excerpt?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content: string;
|
||||
|
||||
@IsEnum(ContentFormat)
|
||||
@IsOptional()
|
||||
contentFormat?: ContentFormat;
|
||||
|
||||
@IsEnum(PostStatus)
|
||||
@IsOptional()
|
||||
status?: PostStatus;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
featuredImageUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
featuredImageAlt?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Transform(transformBoolean)
|
||||
isFeatured?: boolean;
|
||||
|
||||
/** Accepts a comma-separated string "tag1,tag2" or a plain string[] */
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
@Transform(transformCSV)
|
||||
tags?: string[];
|
||||
|
||||
/** Accepts a comma-separated string "cat1,cat2" or a plain string[] */
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
@Transform(transformCSV)
|
||||
categories?: string[];
|
||||
}
|
||||
37
backend/src/blog-posts/dto/list-posts-query.dto.ts
Normal file
37
backend/src/blog-posts/dto/list-posts-query.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IsEnum, IsNumberString, IsOptional, IsString } from 'class-validator';
|
||||
import { PostStatus } from '../entities/blog-post.entity';
|
||||
|
||||
export class ListPostsQueryDto {
|
||||
@IsNumberString()
|
||||
@IsOptional()
|
||||
page?: string;
|
||||
|
||||
@IsNumberString()
|
||||
@IsOptional()
|
||||
pageSize?: string;
|
||||
|
||||
/** Full-text search on title + excerpt */
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
q?: string;
|
||||
|
||||
/** Comma-separated tags e.g. "nestjs,htmx" */
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tags?: string;
|
||||
|
||||
/** Single category filter */
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
/** Sort order: newest | oldest | most_viewed | featured */
|
||||
@IsEnum(['newest', 'oldest', 'most_viewed', 'featured'])
|
||||
@IsOptional()
|
||||
sort?: string;
|
||||
|
||||
/** Admin/manager status filter */
|
||||
@IsEnum(PostStatus)
|
||||
@IsOptional()
|
||||
status?: PostStatus;
|
||||
}
|
||||
4
backend/src/blog-posts/dto/update-post.dto.ts
Normal file
4
backend/src/blog-posts/dto/update-post.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePostDto } from './create-post.dto';
|
||||
|
||||
export class UpdatePostDto extends PartialType(CreatePostDto) {}
|
||||
82
backend/src/blog-posts/entities/blog-post.entity.ts
Normal file
82
backend/src/blog-posts/entities/blog-post.entity.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
export enum PostStatus {
|
||||
DRAFT = 'draft',
|
||||
PUBLISHED = 'published',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export enum ContentFormat {
|
||||
MARKDOWN = 'markdown',
|
||||
HTML = 'html',
|
||||
}
|
||||
|
||||
@Entity('blog_posts')
|
||||
export class BlogPost {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ length: 300, nullable: true, unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: PostStatus.DRAFT })
|
||||
status: PostStatus;
|
||||
|
||||
@Column({ type: 'text', default: '' })
|
||||
excerpt: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@Column({ name: 'content_format', type: 'varchar', length: 20, default: ContentFormat.MARKDOWN })
|
||||
contentFormat: ContentFormat;
|
||||
|
||||
@Column({ name: 'author_id' })
|
||||
authorId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'author_id' })
|
||||
author: User;
|
||||
|
||||
@Column({ name: 'featured_image_url', length: 500, nullable: true })
|
||||
featuredImageUrl: string;
|
||||
|
||||
@Column({ name: 'featured_image_alt', length: 255, nullable: true })
|
||||
featuredImageAlt: string;
|
||||
|
||||
@Column({ name: 'is_featured', default: false })
|
||||
isFeatured: boolean;
|
||||
|
||||
@Column({ default: 0 })
|
||||
views: number;
|
||||
|
||||
@Column({ type: 'simple-array', default: '' })
|
||||
tags: string[];
|
||||
|
||||
@Column({ type: 'simple-array', default: '' })
|
||||
categories: string[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
/** Virtual getter — templates use `post.featuredImage.url` and `post.featuredImage.alt` */
|
||||
get featuredImage(): { url: string; alt: string } | null {
|
||||
if (!this.featuredImageUrl) return null;
|
||||
return { url: this.featuredImageUrl, alt: this.featuredImageAlt || '' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user