From abe6208e4e202f1afbece9f5e490859d3b7581aa Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 16 Sep 2026 11:40:05 +0200 Subject: [PATCH] Add blog thumbnail and banner upload endpoints with storage keys. Store image filenames in DB, resolve paths for cleanup on replace/delete, and reuse product-thumb multer limits. --- backend/src/config/uploadPaths.ts | 14 +++++ .../migrations/1784900000000-add-blog.ts | 2 +- .../modules/blog/config/blogImageUpload.ts | 12 +++++ .../blog/controllers/BlogPostsController.ts | 24 ++++++++- backend/src/modules/blog/entities/BlogPost.ts | 4 +- .../blog/services/BlogPostsService.spec.ts | 52 ++++++++++++++++--- .../modules/blog/services/BlogPostsService.ts | 45 ++++++++++++++-- 7 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 backend/src/modules/blog/config/blogImageUpload.ts diff --git a/backend/src/config/uploadPaths.ts b/backend/src/config/uploadPaths.ts index df70f11..d783f8e 100644 --- a/backend/src/config/uploadPaths.ts +++ b/backend/src/config/uploadPaths.ts @@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/'; export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`; +export const getBlogImagesDir = (): string => join(getPublicUploadsDir(), 'blog', 'images'); + +export const blogImagesUrlPrefix = '/uploads/public/blog/images/'; + +export const getBlogImagePublicUrl = (filename: string): string => `${blogImagesUrlPrefix}${filename}`; + +export const resolveBlogImagePath = (storageKey: string): string => { + const baseDir = getBlogImagesDir(); + + const absolutePath = resolvePathWithinDir(baseDir, storageKey); + + return absolutePath; +}; + export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop'); export const shopBrandingUrlPrefix = '/uploads/public/shop/'; diff --git a/backend/src/database/migrations/1784900000000-add-blog.ts b/backend/src/database/migrations/1784900000000-add-blog.ts index afe532e..d7dd4ed 100644 --- a/backend/src/database/migrations/1784900000000-add-blog.ts +++ b/backend/src/database/migrations/1784900000000-add-blog.ts @@ -5,7 +5,7 @@ export class AddBlog1784900000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailUrl" character varying, "bannerUrl" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))` + `CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailStorageKey" character varying, "bannerStorageKey" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))` ); await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`); } diff --git a/backend/src/modules/blog/config/blogImageUpload.ts b/backend/src/modules/blog/config/blogImageUpload.ts new file mode 100644 index 0000000..38f4927 --- /dev/null +++ b/backend/src/modules/blog/config/blogImageUpload.ts @@ -0,0 +1,12 @@ +import { getProductThumbMulterConfig } from '../../../config'; +import { getBlogImagesDir } from '../../../config/uploadPaths'; +import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions'; +import { createUploadFilePipe } from '../../../utils/createUploadFilePipe'; + +const uploadDir = getBlogImagesDir(); + +export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir); + +const { allowedMimes, maxFileBytes } = getProductThumbMulterConfig(); + +export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes); diff --git a/backend/src/modules/blog/controllers/BlogPostsController.ts b/backend/src/modules/blog/controllers/BlogPostsController.ts index fd2b3d3..66b05ae 100644 --- a/backend/src/modules/blog/controllers/BlogPostsController.ts +++ b/backend/src/modules/blog/controllers/BlogPostsController.ts @@ -10,9 +10,13 @@ import { Patch, Post, Query, - UseGuards + UploadedFile, + UseGuards, + UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { JwtGuard } from '../../../guards/JwtGuard'; +import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload'; import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; import { BlogPostsService } from '../services/BlogPostsService'; @@ -42,6 +46,24 @@ export class BlogPostsController { return this.blogPostsService.update(id, dto); } + @Post('/:id/thumbnail') + @UseInterceptors(FileInterceptor('file', blogImageUploadOptions)) + uploadThumbnail( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile(blogImageFilePipe) file: Express.Multer.File + ) { + return this.blogPostsService.uploadThumbnail(id, file.filename); + } + + @Post('/:id/banner') + @UseInterceptors(FileInterceptor('file', blogImageUploadOptions)) + uploadBanner( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile(blogImageFilePipe) file: Express.Multer.File + ) { + return this.blogPostsService.uploadBanner(id, file.filename); + } + @Delete('/:id') @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/backend/src/modules/blog/entities/BlogPost.ts b/backend/src/modules/blog/entities/BlogPost.ts index b5b1402..c2b3689 100644 --- a/backend/src/modules/blog/entities/BlogPost.ts +++ b/backend/src/modules/blog/entities/BlogPost.ts @@ -9,10 +9,10 @@ export class BlogPost { title: string; @Column({ type: 'varchar', nullable: true }) - thumbnailUrl: string | null; + thumbnailStorageKey: string | null; @Column({ type: 'varchar', nullable: true }) - bannerUrl: string | null; + bannerStorageKey: string | null; @Column({ type: 'text' }) contentHtml: string; diff --git a/backend/src/modules/blog/services/BlogPostsService.spec.ts b/backend/src/modules/blog/services/BlogPostsService.spec.ts index d3faa6a..64d4e5f 100644 --- a/backend/src/modules/blog/services/BlogPostsService.spec.ts +++ b/backend/src/modules/blog/services/BlogPostsService.spec.ts @@ -1,8 +1,14 @@ import { NotFoundException } from '@nestjs/common'; import type { Repository } from 'typeorm'; +import { resolveBlogImagePath } from '../../../config/uploadPaths'; +import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import { BlogPost } from '../entities/BlogPost'; import { BlogPostsService } from './BlogPostsService'; +jest.mock('../../../utils/removeFileFromDisk', () => ({ + removeFileFromDisk: jest.fn().mockResolvedValue(undefined) +})); + describe('BlogPostsService', () => { let service: BlogPostsService; let blogPostRepo: { @@ -10,21 +16,21 @@ describe('BlogPostsService', () => { findOne: jest.Mock; create: jest.Mock; insert: jest.Mock; - save: jest.Mock; update: jest.Mock; exists: jest.Mock; delete: jest.Mock; }; beforeEach(() => { + jest.mocked(removeFileFromDisk).mockClear(); + 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), + update: jest.fn().mockResolvedValue(undefined), + exists: jest.fn().mockResolvedValue(false), delete: jest.fn().mockResolvedValue(undefined) }; @@ -89,6 +95,31 @@ describe('BlogPostsService', () => { }); }); + it('uploads a thumbnail and removes the previous file', async () => { + blogPostRepo.findOne + .mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'old.webp' }) + .mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'new.webp' }); + + await service.uploadThumbnail('post-1', 'new.webp'); + + expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { thumbnailStorageKey: 'new.webp' }); + expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('old.webp'), BlogPostsService.name); + }); + + it('uploads a banner and removes the previous file', async () => { + blogPostRepo.findOne + .mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'old-banner.webp' }) + .mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'new-banner.webp' }); + + await service.uploadBanner('post-1', 'new-banner.webp'); + + expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { bannerStorageKey: 'new-banner.webp' }); + expect(removeFileFromDisk).toHaveBeenCalledWith( + resolveBlogImagePath('old-banner.webp'), + BlogPostsService.name + ); + }); + it('throws when loading a missing post', async () => { await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); }); @@ -105,12 +136,21 @@ describe('BlogPostsService', () => { ).rejects.toThrow(NotFoundException); }); - it('deletes an existing post', async () => { - blogPostRepo.exists.mockResolvedValue(true); + it('deletes an existing post and its image files', async () => { + blogPostRepo.findOne.mockResolvedValueOnce({ + id: 'post-1', + thumbnailStorageKey: 'thumb.webp', + bannerStorageKey: 'banner.webp' + }); await service.remove('post-1'); expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1'); + expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('thumb.webp'), BlogPostsService.name); + expect(removeFileFromDisk).toHaveBeenCalledWith( + resolveBlogImagePath('banner.webp'), + BlogPostsService.name + ); }); it('throws when deleting a missing post', async () => { diff --git a/backend/src/modules/blog/services/BlogPostsService.ts b/backend/src/modules/blog/services/BlogPostsService.ts index f88b0d2..72a24ea 100644 --- a/backend/src/modules/blog/services/BlogPostsService.ts +++ b/backend/src/modules/blog/services/BlogPostsService.ts @@ -1,7 +1,9 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { resolveBlogImagePath } from '../../../config/uploadPaths'; import type { PaginatedResponse } from '../../../types/PaginatedResponse'; +import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; import { BlogPost } from '../entities/BlogPost'; @@ -61,13 +63,46 @@ export class BlogPostsService { return this.findOne(id); } - async remove(id: string): Promise { - const exists = await this.blogPostRepo.exists({ where: { id } }); + async uploadThumbnail(id: string, filename: string): Promise { + const post = await this.findOne(id); - if (!exists) { - throw new NotFoundException(); - } + const previousStorageKey = post.thumbnailStorageKey; + + await this.blogPostRepo.update(id, { thumbnailStorageKey: filename }); + await this.removeBlogImageFile(previousStorageKey); + + return this.findOne(id); + } + + async uploadBanner(id: string, filename: string): Promise { + const post = await this.findOne(id); + + const previousStorageKey = post.bannerStorageKey; + + await this.blogPostRepo.update(id, { bannerStorageKey: filename }); + await this.removeBlogImageFile(previousStorageKey); + + return this.findOne(id); + } + + async remove(id: string): Promise { + const post = await this.findOne(id); await this.blogPostRepo.delete(id); + + await Promise.all([ + this.removeBlogImageFile(post.thumbnailStorageKey), + this.removeBlogImageFile(post.bannerStorageKey) + ]); + } + + private async removeBlogImageFile(storageKey: string | null): Promise { + if (!storageKey) { + return; + } + + const path = resolveBlogImagePath(storageKey); + + await removeFileFromDisk(path, BlogPostsService.name); } }