diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 3e38a2e..90f2e7e 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -20,6 +20,7 @@ import { } from './config'; import { validate } from './config/validate'; import { AuthModule } from './modules/auth/AuthModule'; +import { BlogModule } from './modules/blog/BlogModule'; import { EncryptionModule } from './modules/encryption/EncryptionModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; @@ -85,6 +86,7 @@ import { Config } from './types/Config'; HealthCheckModule, EncryptionModule, AuthModule, + BlogModule, ProductsModule, ShopSettingsModule, SimplexModule, diff --git a/backend/src/modules/blog/BlogModule.ts b/backend/src/modules/blog/BlogModule.ts new file mode 100644 index 0000000..30b0433 --- /dev/null +++ b/backend/src/modules/blog/BlogModule.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BlogPostsController } from './controllers/BlogPostsController'; +import { BlogPost } from './entities/BlogPost'; +import { BlogPostsService } from './services/BlogPostsService'; + +@Module({ + imports: [TypeOrmModule.forFeature([BlogPost])], + controllers: [BlogPostsController], + providers: [BlogPostsService] +}) +export class BlogModule {} diff --git a/backend/src/modules/blog/controllers/BlogPostsController.ts b/backend/src/modules/blog/controllers/BlogPostsController.ts new file mode 100644 index 0000000..fd2b3d3 --- /dev/null +++ b/backend/src/modules/blog/controllers/BlogPostsController.ts @@ -0,0 +1,50 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; +import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; +import { BlogPostsService } from '../services/BlogPostsService'; + +@Controller('blog-posts') +@UseGuards(JwtGuard) +export class BlogPostsController { + constructor(private readonly blogPostsService: BlogPostsService) {} + + @Post('/') + createDraft() { + return this.blogPostsService.createDraft(); + } + + @Get('/') + findAll(@Query() query: ListBlogPostsQueryDto) { + return this.blogPostsService.findAll(query); + } + + @Get('/:id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.blogPostsService.findOne(id); + } + + @Patch('/:id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) { + return this.blogPostsService.update(id, dto); + } + + @Delete('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.blogPostsService.remove(id); + } +} diff --git a/backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts b/backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts new file mode 100644 index 0000000..ccf74f1 --- /dev/null +++ b/backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListBlogPostsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/backend/src/modules/blog/dto/UpdateBlogPostDto.ts b/backend/src/modules/blog/dto/UpdateBlogPostDto.ts new file mode 100644 index 0000000..4eff67a --- /dev/null +++ b/backend/src/modules/blog/dto/UpdateBlogPostDto.ts @@ -0,0 +1,21 @@ +import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +import { getAppConfig } from '../../../config'; + +const { + validation: { blogPostTitleMaxLength } +} = getAppConfig(); + +export class UpdateBlogPostDto { + @IsNotEmpty() + @IsString() + @MaxLength(blogPostTitleMaxLength) + title: string; + + @IsString() + contentHtml: string; + + @IsNotEmpty() + @IsBoolean() + isDraft: boolean; +} diff --git a/backend/src/modules/blog/services/BlogPostsService.spec.ts b/backend/src/modules/blog/services/BlogPostsService.spec.ts new file mode 100644 index 0000000..d3faa6a --- /dev/null +++ b/backend/src/modules/blog/services/BlogPostsService.spec.ts @@ -0,0 +1,120 @@ +import { NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import { BlogPost } from '../entities/BlogPost'; +import { BlogPostsService } from './BlogPostsService'; + +describe('BlogPostsService', () => { + let service: BlogPostsService; + let blogPostRepo: { + findAndCount: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + insert: jest.Mock; + save: jest.Mock; + update: jest.Mock; + exists: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(() => { + blogPostRepo = { + findAndCount: jest.fn().mockResolvedValue([[], 0]), + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn(data => ({ id: 'post-1', ...data })), + insert: jest.fn().mockResolvedValue(undefined), + save: jest.fn(async (entity: BlogPost) => entity), + update: jest.fn().mockResolvedValue(undefined), + exists: jest.fn().mockResolvedValue(false), + delete: jest.fn().mockResolvedValue(undefined) + }; + + service = new BlogPostsService(blogPostRepo as unknown as Repository); + }); + + it('creates a draft post with empty title and content', async () => { + blogPostRepo.findOne.mockResolvedValue({ + id: 'post-1', + title: '', + contentHtml: '', + isDraft: true + }); + + const created = await service.createDraft(); + + expect(blogPostRepo.create).toHaveBeenCalledWith({ + title: '', + contentHtml: '', + isDraft: true + }); + expect(blogPostRepo.insert).toHaveBeenCalled(); + expect(created).toEqual( + expect.objectContaining({ + id: 'post-1', + title: '', + contentHtml: '', + isDraft: true + }) + ); + }); + + it('lists posts ordered by createdAt descending', async () => { + await service.findAll({ page: 2, limit: 10 }); + + expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({ + order: { createdAt: 'DESC' }, + skip: 10, + take: 10 + }); + }); + + it('trims title on update', async () => { + blogPostRepo.exists.mockResolvedValueOnce(true); + blogPostRepo.findOne.mockResolvedValueOnce({ + id: 'post-1', + title: 'Hello', + contentHtml: '

Body

', + isDraft: false + }); + + await service.update('post-1', { + title: ' Hello ', + contentHtml: '

Body

', + isDraft: false + }); + + expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { + title: 'Hello', + contentHtml: '

Body

', + isDraft: false + }); + }); + + it('throws when loading a missing post', async () => { + await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); + }); + + it('throws when updating a missing post', async () => { + blogPostRepo.exists.mockResolvedValueOnce(false); + + await expect( + service.update('missing-id', { + title: 'Hello', + contentHtml: '', + isDraft: true + }) + ).rejects.toThrow(NotFoundException); + }); + + it('deletes an existing post', async () => { + blogPostRepo.exists.mockResolvedValue(true); + + await service.remove('post-1'); + + expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1'); + }); + + it('throws when deleting a missing post', async () => { + await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); + expect(blogPostRepo.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/blog/services/BlogPostsService.ts b/backend/src/modules/blog/services/BlogPostsService.ts new file mode 100644 index 0000000..f88b0d2 --- /dev/null +++ b/backend/src/modules/blog/services/BlogPostsService.ts @@ -0,0 +1,73 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import type { PaginatedResponse } from '../../../types/PaginatedResponse'; +import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; +import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; +import { BlogPost } from '../entities/BlogPost'; + +@Injectable() +export class BlogPostsService { + constructor( + @InjectRepository(BlogPost) + private readonly blogPostRepo: Repository + ) {} + + async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise> { + const [items, total] = await this.blogPostRepo.findAndCount({ + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit + }); + + return { items, total, page, limit }; + } + + async createDraft(): Promise { + const entity = this.blogPostRepo.create({ + title: '', + contentHtml: '', + isDraft: true + }); + + await this.blogPostRepo.insert(entity); + + return this.findOne(entity.id); + } + + async findOne(id: string): Promise { + const entity = await this.blogPostRepo.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException(); + } + + return entity; + } + + async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise { + const exists = await this.blogPostRepo.exists({ where: { id } }); + + if (!exists) { + throw new NotFoundException(); + } + + await this.blogPostRepo.update(id, { + title: title.trim(), + contentHtml, + isDraft + }); + + return this.findOne(id); + } + + async remove(id: string): Promise { + const exists = await this.blogPostRepo.exists({ where: { id } }); + + if (!exists) { + throw new NotFoundException(); + } + + await this.blogPostRepo.delete(id); + } +}