Slave/blog #9

Merged
nobswebdev merged 14 commits from slave/blog into master 2026-09-16 20:15:08 +00:00
7 changed files with 138 additions and 15 deletions
Showing only changes of commit abe6208e4e - Show all commits
+14
View File
@@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/';
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`; 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 getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
export const shopBrandingUrlPrefix = '/uploads/public/shop/'; export const shopBrandingUrlPrefix = '/uploads/public/shop/';
@@ -5,7 +5,7 @@ export class AddBlog1784900000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query( 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`); await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`);
} }
@@ -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);
@@ -10,9 +10,13 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
UseGuards UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtGuard } from '../../../guards/JwtGuard'; import { JwtGuard } from '../../../guards/JwtGuard';
import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload';
import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
import { BlogPostsService } from '../services/BlogPostsService'; import { BlogPostsService } from '../services/BlogPostsService';
@@ -42,6 +46,24 @@ export class BlogPostsController {
return this.blogPostsService.update(id, dto); 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') @Delete('/:id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string) { async remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -9,10 +9,10 @@ export class BlogPost {
title: string; title: string;
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
thumbnailUrl: string | null; thumbnailStorageKey: string | null;
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
bannerUrl: string | null; bannerStorageKey: string | null;
@Column({ type: 'text' }) @Column({ type: 'text' })
contentHtml: string; contentHtml: string;
@@ -1,8 +1,14 @@
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import { resolveBlogImagePath } from '../../../config/uploadPaths';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import { BlogPost } from '../entities/BlogPost'; import { BlogPost } from '../entities/BlogPost';
import { BlogPostsService } from './BlogPostsService'; import { BlogPostsService } from './BlogPostsService';
jest.mock('../../../utils/removeFileFromDisk', () => ({
removeFileFromDisk: jest.fn().mockResolvedValue(undefined)
}));
describe('BlogPostsService', () => { describe('BlogPostsService', () => {
let service: BlogPostsService; let service: BlogPostsService;
let blogPostRepo: { let blogPostRepo: {
@@ -10,19 +16,19 @@ describe('BlogPostsService', () => {
findOne: jest.Mock; findOne: jest.Mock;
create: jest.Mock; create: jest.Mock;
insert: jest.Mock; insert: jest.Mock;
save: jest.Mock;
update: jest.Mock; update: jest.Mock;
exists: jest.Mock; exists: jest.Mock;
delete: jest.Mock; delete: jest.Mock;
}; };
beforeEach(() => { beforeEach(() => {
jest.mocked(removeFileFromDisk).mockClear();
blogPostRepo = { blogPostRepo = {
findAndCount: jest.fn().mockResolvedValue([[], 0]), findAndCount: jest.fn().mockResolvedValue([[], 0]),
findOne: jest.fn().mockResolvedValue(null), findOne: jest.fn().mockResolvedValue(null),
create: jest.fn(data => ({ id: 'post-1', ...data })), create: jest.fn(data => ({ id: 'post-1', ...data })),
insert: jest.fn().mockResolvedValue(undefined), insert: jest.fn().mockResolvedValue(undefined),
save: jest.fn(async (entity: BlogPost) => entity),
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
exists: jest.fn().mockResolvedValue(false), exists: jest.fn().mockResolvedValue(false),
delete: jest.fn().mockResolvedValue(undefined) 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 () => { it('throws when loading a missing post', async () => {
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
}); });
@@ -105,12 +136,21 @@ describe('BlogPostsService', () => {
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('deletes an existing post', async () => { it('deletes an existing post and its image files', async () => {
blogPostRepo.exists.mockResolvedValue(true); blogPostRepo.findOne.mockResolvedValueOnce({
id: 'post-1',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: 'banner.webp'
});
await service.remove('post-1'); await service.remove('post-1');
expect(blogPostRepo.delete).toHaveBeenCalledWith('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 () => { it('throws when deleting a missing post', async () => {
@@ -1,7 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { resolveBlogImagePath } from '../../../config/uploadPaths';
import type { PaginatedResponse } from '../../../types/PaginatedResponse'; import type { PaginatedResponse } from '../../../types/PaginatedResponse';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
import { BlogPost } from '../entities/BlogPost'; import { BlogPost } from '../entities/BlogPost';
@@ -61,13 +63,46 @@ export class BlogPostsService {
return this.findOne(id); return this.findOne(id);
} }
async remove(id: string): Promise<void> { async uploadThumbnail(id: string, filename: string): Promise<BlogPost> {
const exists = await this.blogPostRepo.exists({ where: { id } }); const post = await this.findOne(id);
if (!exists) { const previousStorageKey = post.thumbnailStorageKey;
throw new NotFoundException();
await this.blogPostRepo.update(id, { thumbnailStorageKey: filename });
await this.removeBlogImageFile(previousStorageKey);
return this.findOne(id);
} }
async uploadBanner(id: string, filename: string): Promise<BlogPost> {
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<void> {
const post = await this.findOne(id);
await this.blogPostRepo.delete(id); await this.blogPostRepo.delete(id);
await Promise.all([
this.removeBlogImageFile(post.thumbnailStorageKey),
this.removeBlogImageFile(post.bannerStorageKey)
]);
}
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
if (!storageKey) {
return;
}
const path = resolveBlogImagePath(storageKey);
await removeFileFromDisk(path, BlogPostsService.name);
} }
} }