FullStack-Blog-Nestjs-Nextjs-Postgres

This commit is contained in:
2026-02-19 05:24:02 +09:00
parent dcbb3a0670
commit 3d2de67d1e
79 changed files with 3 additions and 3 deletions

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';
export class UpdatePostDto extends PartialType(CreatePostDto) {}

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