From 40bf16c03ed4bb98a9528b3aea5c00f932a22428 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 15 Sep 2026 19:36:30 +0200 Subject: [PATCH 01/14] Add blog schema with BlogPost entity and shop blog toggle. Introduces blog_posts table and blogEnabled on shop_settings for optional storefront blog. --- .../migrations/1784900000000-add-blog.ts | 17 +++++++++++ backend/src/modules/blog/entities/BlogPost.ts | 28 +++++++++++++++++++ .../shopSettings/entities/ShopSettings.ts | 3 ++ 3 files changed, 48 insertions(+) create mode 100644 backend/src/database/migrations/1784900000000-add-blog.ts create mode 100644 backend/src/modules/blog/entities/BlogPost.ts diff --git a/backend/src/database/migrations/1784900000000-add-blog.ts b/backend/src/database/migrations/1784900000000-add-blog.ts new file mode 100644 index 0000000..afe532e --- /dev/null +++ b/backend/src/database/migrations/1784900000000-add-blog.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlog1784900000000 implements MigrationInterface { + name = 'AddBlog1784900000000'; + + 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"))` + ); + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "blogEnabled"`); + await queryRunner.query(`DROP TABLE "blog_posts"`); + } +} diff --git a/backend/src/modules/blog/entities/BlogPost.ts b/backend/src/modules/blog/entities/BlogPost.ts new file mode 100644 index 0000000..b5b1402 --- /dev/null +++ b/backend/src/modules/blog/entities/BlogPost.ts @@ -0,0 +1,28 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('blog_posts') +export class BlogPost { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column({ type: 'varchar', nullable: true }) + thumbnailUrl: string | null; + + @Column({ type: 'varchar', nullable: true }) + bannerUrl: string | null; + + @Column({ type: 'text' }) + contentHtml: string; + + @Column({ type: 'boolean', default: true }) + isDraft: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/shopSettings/entities/ShopSettings.ts b/backend/src/modules/shopSettings/entities/ShopSettings.ts index 839bfa0..66eec16 100644 --- a/backend/src/modules/shopSettings/entities/ShopSettings.ts +++ b/backend/src/modules/shopSettings/entities/ShopSettings.ts @@ -32,6 +32,9 @@ export class ShopSettings { @Column({ type: 'integer', nullable: true }) simplexNotificationContactId: number | null; + @Column({ type: 'boolean', default: false }) + blogEnabled: boolean; + @CreateDateColumn() createdAt: Date; From 92ccbec5455f25817604c959c655b2962d48fc28 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 15 Sep 2026 20:05:25 +0200 Subject: [PATCH 02/14] add blog post title validation config --- .env.example | 1 + backend/src/config/index.ts | 1 + backend/src/config/validate.ts | 6 ++++++ backend/src/types/Config.ts | 1 + 4 files changed, 9 insertions(+) diff --git a/.env.example b/.env.example index 3d78ee4..74bec8d 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,7 @@ MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,im MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 +VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200 VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 VALIDATION_VARIANT_IMAGES_MAX=10 diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ae81352..22abba6 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -97,6 +97,7 @@ export const getAppConfig = (): AppConfig => { }, validation: { productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), + blogPostTitleMaxLength: envInt('VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'), categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'), discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'), diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts index 87ae559..9a7eca8 100644 --- a/backend/src/config/validate.ts +++ b/backend/src/config/validate.ts @@ -101,6 +101,12 @@ class EnvironmentVariables { @Max(256) VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number; + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(256) + VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: number; + @IsNotEmpty() @IsNumber() @Min(1) diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts index b63259b..5123cf6 100644 --- a/backend/src/types/Config.ts +++ b/backend/src/types/Config.ts @@ -42,6 +42,7 @@ export interface AppConfig { signedCookie: SignedCookieConfig; validation: { productTitleMaxLength: number; + blogPostTitleMaxLength: number; categoryNameMaxLength: number; discountCodeMaxLength: number; variantImagesMax: number; From d38def23026390218724da339eb040338ca4c511 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 15 Sep 2026 20:22:49 +0200 Subject: [PATCH 03/14] Add blog post admin CRUD API. Expose JWT-protected /blog-posts endpoints for draft creation, listing, update, and delete. --- backend/src/AppModule.ts | 2 + backend/src/modules/blog/BlogModule.ts | 12 ++ .../blog/controllers/BlogPostsController.ts | 50 ++++++++ .../modules/blog/dto/ListBlogPostsQueryDto.ts | 17 +++ .../src/modules/blog/dto/UpdateBlogPostDto.ts | 21 +++ .../blog/services/BlogPostsService.spec.ts | 120 ++++++++++++++++++ .../modules/blog/services/BlogPostsService.ts | 73 +++++++++++ 7 files changed, 295 insertions(+) create mode 100644 backend/src/modules/blog/BlogModule.ts create mode 100644 backend/src/modules/blog/controllers/BlogPostsController.ts create mode 100644 backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts create mode 100644 backend/src/modules/blog/dto/UpdateBlogPostDto.ts create mode 100644 backend/src/modules/blog/services/BlogPostsService.spec.ts create mode 100644 backend/src/modules/blog/services/BlogPostsService.ts 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); + } +} From abe6208e4e202f1afbece9f5e490859d3b7581aa Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 16 Sep 2026 11:40:05 +0200 Subject: [PATCH 04/14] 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); } } From 73f7ffed007f17b668181dfb6956ac5bb2ab792d Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 16 Sep 2026 11:48:59 +0200 Subject: [PATCH 05/14] Expose blog toggle via shop settings API and storefront branding. Add PATCH /shop-settings/blog-enabled and surface blogEnabled in ShopSettingsView and StorefrontBranding. --- .../controllers/ShopSettingsController.ts | 6 ++++++ .../shopSettings/dto/UpdateBlogEnabledDto.ts | 7 +++++++ .../services/ShopSettingsService.spec.ts | 20 ++++++++++++++++++- .../services/ShopSettingsService.ts | 14 ++++++++++++- .../shopSettings/types/ShopSettingsView.ts | 1 + .../shopSettings/types/StorefrontBranding.ts | 1 + .../StorefrontShopViewService.spec.ts | 3 ++- .../StorefrontShopViewServiceTestTypes.ts | 1 + 8 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts diff --git a/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts b/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts index 6480a70..639fe4c 100644 --- a/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts +++ b/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts @@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard'; import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload'; import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload'; import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto'; +import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto'; import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; @@ -43,6 +44,11 @@ export class ShopSettingsController { return this.shopSettingsService.updateNotifications(dto); } + @Patch('/blog-enabled') + updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) { + return this.shopSettingsService.updateBlogEnabled(dto); + } + @Post('/simplex-connect') connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) { return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink); diff --git a/backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts b/backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts new file mode 100644 index 0000000..0e40ce4 --- /dev/null +++ b/backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +export class UpdateBlogEnabledDto { + @IsNotEmpty() + @IsBoolean() + blogEnabled: boolean; +} diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts index 2b4b964..a03818a 100644 --- a/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts @@ -25,6 +25,7 @@ describe('ShopSettingsService', () => { notifyOnNewOrder: false, notifyOnOrderMessage: false, simplexNotificationContactId: null, + blogEnabled: false, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z') } as ShopSettings; @@ -67,10 +68,27 @@ describe('ShopSettingsService', () => { logoUrl: '/uploads/public/shop/logo.png', faviconUrl: null, simplexLink: 'https://simplex.example', - shippingNote: 'Ships in 3 days' + shippingNote: 'Ships in 3 days', + blogEnabled: false }); }); + it('updates blog enabled flag', async () => { + await service.updateBlogEnabled({ blogEnabled: true }); + + expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { blogEnabled: true }); + }); + + it('includes blog enabled in shop settings view', async () => { + shopSettingsRepo.find.mockResolvedValue([{ ...settings, blogEnabled: true }]); + + await expect(service.getView()).resolves.toEqual( + expect.objectContaining({ + blogEnabled: true + }) + ); + }); + it('trims simplex links on update', async () => { await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' }); diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.ts index 293e154..f53b3cd 100644 --- a/backend/src/modules/shopSettings/services/ShopSettingsService.ts +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.ts @@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf import { Config } from '../../../types/Config'; import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; +import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto'; import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; @@ -43,7 +44,8 @@ export class ShopSettingsService { logoUrl, faviconUrl, simplexLink: settings.simplexLink, - shippingNote: settings.shippingNote + shippingNote: settings.shippingNote, + blogEnabled: settings.blogEnabled }; } @@ -93,6 +95,14 @@ export class ShopSettingsService { return this.getView(); } + async updateBlogEnabled({ blogEnabled }: UpdateBlogEnabledDto): Promise { + const settings = await this.findSettings(); + + await this.shopSettingsRepo.update(settings.id, { blogEnabled }); + + return this.getView(); + } + async uploadLogo(filename: string): Promise { const settings = await this.findSettings(); @@ -191,6 +201,7 @@ export class ShopSettingsService { notifyOnNewOrder, notifyOnOrderMessage, simplexNotificationContactId, + blogEnabled, createdAt, updatedAt }: ShopSettings): ShopSettingsView { @@ -220,6 +231,7 @@ export class ShopSettingsService { notifyOnNewOrder, notifyOnOrderMessage, simplexNotificationConnected: simplexNotificationContactId !== null, + blogEnabled, isSetupComplete, setupChecklist, createdAt, diff --git a/backend/src/modules/shopSettings/types/ShopSettingsView.ts b/backend/src/modules/shopSettings/types/ShopSettingsView.ts index 9c889de..cd7b6fa 100644 --- a/backend/src/modules/shopSettings/types/ShopSettingsView.ts +++ b/backend/src/modules/shopSettings/types/ShopSettingsView.ts @@ -18,6 +18,7 @@ export interface ShopSettingsView { notifyOnNewOrder: boolean; notifyOnOrderMessage: boolean; simplexNotificationConnected: boolean; + blogEnabled: boolean; isSetupComplete: boolean; setupChecklist: SetupChecklist; createdAt: Date | null; diff --git a/backend/src/modules/shopSettings/types/StorefrontBranding.ts b/backend/src/modules/shopSettings/types/StorefrontBranding.ts index 39d5da5..1eaca45 100644 --- a/backend/src/modules/shopSettings/types/StorefrontBranding.ts +++ b/backend/src/modules/shopSettings/types/StorefrontBranding.ts @@ -3,4 +3,5 @@ export interface StorefrontBranding { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; + blogEnabled: boolean; } diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts index ca6dda6..80b877c 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -29,7 +29,8 @@ describe('StorefrontShopViewService', () => { logoUrl: null, faviconUrl: null, simplexLink: null, - shippingNote: null + shippingNote: null, + blogEnabled: false }, shopSettings = { shopName: 'Demo Shop', diff --git a/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts b/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts index 4b68795..03847da 100644 --- a/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts +++ b/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts @@ -13,6 +13,7 @@ export type StorefrontShopViewServiceOverrides = { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; + blogEnabled: boolean; }; shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] }; fiatPerXmr?: number; From a3d1842fb324616ea1eda7186a45b8a484e1a6bc Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 16 Sep 2026 13:55:12 +0200 Subject: [PATCH 06/14] Add storefront blog pages with nav, templates, and SEO meta. Wire blogEnabled into shop render locals and expose public blog index and detail routes when the shop toggle is on. --- backend/src/AppModule.ts | 4 +- backend/src/main.ts | 2 + backend/src/modules/blog/BlogModule.ts | 3 +- .../storefrontBlog/StorefrontBlogModule.ts | 14 ++++ .../controllers/StorefrontBlogController.ts | 69 +++++++++++++++++++ .../StorefrontBlogPostsService.spec.ts | 42 +++++++++++ .../services/StorefrontBlogPostsService.ts | 31 +++++++++ .../StorefrontBlogViewService.spec.ts | 34 +++++++++ .../services/StorefrontBlogViewService.ts | 27 ++++++++ .../types/StorefrontBlogPostCardView.ts | 6 ++ .../types/StorefrontBlogPostDetailView.ts | 7 ++ .../storefrontCore/public/css/storefront.css | 61 ++++++++++++++++ .../StorefrontShopViewService.spec.ts | 4 +- .../services/StorefrontShopViewService.ts | 3 +- .../storefrontCore/types/ShopNavKey.ts | 2 +- .../storefrontCore/types/ShopRenderLocals.ts | 1 + .../utils/registerStorefrontPartials.ts | 1 + .../utils/resolveShopNavActive.spec.ts | 5 ++ .../utils/resolveShopNavActive.ts | 4 ++ .../storefrontCore/views/blog-detail.hbs | 16 +++++ .../storefrontCore/views/blog-index.hbs | 11 +++ .../views/partials/blog-card.hbs | 14 ++++ .../views/partials/shop-nav.hbs | 3 + 23 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 backend/src/modules/storefrontBlog/StorefrontBlogModule.ts create mode 100644 backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts create mode 100644 backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts create mode 100644 backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts create mode 100644 backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts create mode 100644 backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts create mode 100644 backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts create mode 100644 backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts create mode 100644 backend/src/modules/storefrontCore/views/blog-detail.hbs create mode 100644 backend/src/modules/storefrontCore/views/blog-index.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/blog-card.hbs diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 90f2e7e..2933a85 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -38,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; +import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule'; import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule'; import { Config } from './types/Config'; @@ -101,7 +102,8 @@ import { Config } from './types/Config'; StorefrontProductModule, StorefrontCartModule, StorefrontCheckoutModule, - StorefrontOrderModule + StorefrontOrderModule, + StorefrontBlogModule ], providers: [ { diff --git a/backend/src/main.ts b/backend/src/main.ts index 2f3ab93..3b3f0e7 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -77,6 +77,8 @@ async function bootstrap() { { path: '/', method: RequestMethod.GET }, { path: 'shop/categories/:id', method: RequestMethod.GET }, { path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET }, + { path: 'shop/blog', method: RequestMethod.GET }, + { path: 'shop/blog/:id', method: RequestMethod.GET }, { path: 'shop/assets/{*path}', method: RequestMethod.GET }, { path: 'shop/preferences/theme', method: RequestMethod.POST }, { path: 'shop/error', method: RequestMethod.GET }, diff --git a/backend/src/modules/blog/BlogModule.ts b/backend/src/modules/blog/BlogModule.ts index 30b0433..9af786d 100644 --- a/backend/src/modules/blog/BlogModule.ts +++ b/backend/src/modules/blog/BlogModule.ts @@ -7,6 +7,7 @@ import { BlogPostsService } from './services/BlogPostsService'; @Module({ imports: [TypeOrmModule.forFeature([BlogPost])], controllers: [BlogPostsController], - providers: [BlogPostsService] + providers: [BlogPostsService], + exports: [TypeOrmModule.forFeature([BlogPost])] }) export class BlogModule {} diff --git a/backend/src/modules/storefrontBlog/StorefrontBlogModule.ts b/backend/src/modules/storefrontBlog/StorefrontBlogModule.ts new file mode 100644 index 0000000..eaff28c --- /dev/null +++ b/backend/src/modules/storefrontBlog/StorefrontBlogModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { BlogModule } from '../blog/BlogModule'; +import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule'; +import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; +import { StorefrontBlogController } from './controllers/StorefrontBlogController'; +import { StorefrontBlogPostsService } from './services/StorefrontBlogPostsService'; +import { StorefrontBlogViewService } from './services/StorefrontBlogViewService'; + +@Module({ + imports: [BlogModule, ShopSettingsModule, StorefrontCoreModule], + controllers: [StorefrontBlogController], + providers: [StorefrontBlogPostsService, StorefrontBlogViewService] +}) +export class StorefrontBlogModule {} diff --git a/backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts b/backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts new file mode 100644 index 0000000..ef1264d --- /dev/null +++ b/backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts @@ -0,0 +1,69 @@ +import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Req, Res, UseFilters } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; +import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter'; +import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService'; +import { StorefrontBlogPostsService } from '../services/StorefrontBlogPostsService'; +import { StorefrontBlogViewService } from '../services/StorefrontBlogViewService'; + +@Controller() +@UseFilters(StorefrontExceptionFilter) +export class StorefrontBlogController { + constructor( + private readonly blogPostsService: StorefrontBlogPostsService, + private readonly blogViewService: StorefrontBlogViewService, + private readonly shopViewService: StorefrontShopViewService, + private readonly shopSettingsService: ShopSettingsService + ) {} + + @Get('shop/blog') + async blogIndex(@Req() req: Request, @Res() res: Response) { + const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding(); + + if (!blogEnabled) { + throw new NotFoundException('We could not find that page.'); + } + + const [posts, shopLocals] = await Promise.all([ + this.blogPostsService.listLivePosts(), + this.shopViewService.buildShopRenderLocals(req, res, { + title: 'Blog', + metaDescription: 'News and updates from {shopName}.' + }) + ]); + + const postsView = this.blogViewService.toPostsIndexView(posts); + + return res.render('blog-index', { + posts: postsView, + ...shopLocals + }); + } + + @Get('shop/blog/:id') + async blogDetail(@Param('id', ParseUUIDPipe) id: string, @Req() req: Request, @Res() res: Response) { + const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding(); + + if (!blogEnabled) { + throw new NotFoundException('We could not find that page.'); + } + + const post = await this.blogPostsService.getLivePost(id); + + const postView = this.blogViewService.toPostDetailView(post); + + const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, { + title: post.title, + metaDescription: `Read ${post.title} at {shopName}.`, + ogType: 'article', + ogImageUrl: postView.bannerUrl ?? postView.thumbnailUrl + }); + + return res.render('blog-detail', { + post: postView, + backHref: '/shop/blog', + backLabel: 'Blog', + ...shopLocals + }); + } +} diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts new file mode 100644 index 0000000..99f4dfc --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts @@ -0,0 +1,42 @@ +import { NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import { BlogPost } from '../../blog/entities/BlogPost'; +import { StorefrontBlogPostsService } from './StorefrontBlogPostsService'; + +describe('StorefrontBlogPostsService', () => { + let service: StorefrontBlogPostsService; + let blogPostRepo: { + find: jest.Mock; + findOne: jest.Mock; + }; + + beforeEach(() => { + blogPostRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null) + }; + + service = new StorefrontBlogPostsService(blogPostRepo as unknown as Repository); + }); + + it('lists live posts ordered by createdAt descending', async () => { + await service.listLivePosts(); + + expect(blogPostRepo.find).toHaveBeenCalledWith({ + where: { isDraft: false }, + order: { createdAt: 'DESC' } + }); + }); + + it('returns a live post by id', async () => { + const post = { id: 'post-1', title: 'Hello', isDraft: false } as BlogPost; + + blogPostRepo.findOne.mockResolvedValue(post); + + await expect(service.getLivePost('post-1')).resolves.toBe(post); + }); + + it('throws when a draft or missing post is requested', async () => { + await expect(service.getLivePost('missing-id')).rejects.toThrow(NotFoundException); + }); +}); diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts new file mode 100644 index 0000000..ca34768 --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts @@ -0,0 +1,31 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BlogPost } from '../../blog/entities/BlogPost'; + +@Injectable() +export class StorefrontBlogPostsService { + constructor( + @InjectRepository(BlogPost) + private readonly blogPostRepo: Repository + ) {} + + async listLivePosts(): Promise { + return this.blogPostRepo.find({ + where: { isDraft: false }, + order: { createdAt: 'DESC' } + }); + } + + async getLivePost(id: string): Promise { + const post = await this.blogPostRepo.findOne({ + where: { id, isDraft: false } + }); + + if (!post) { + throw new NotFoundException('We could not find that blog post.'); + } + + return post; + } +} diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts new file mode 100644 index 0000000..5be0b0f --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts @@ -0,0 +1,34 @@ +import { BlogPost } from '../../blog/entities/BlogPost'; +import { StorefrontBlogViewService } from './StorefrontBlogViewService'; + +describe('StorefrontBlogViewService', () => { + let service: StorefrontBlogViewService; + + beforeEach(() => { + service = new StorefrontBlogViewService(); + }); + + it('maps posts to card views with image urls', () => { + const views = service.toPostsIndexView([ + { + id: 'post-1', + title: 'Hello', + thumbnailStorageKey: 'thumb.webp', + bannerStorageKey: null, + contentHtml: '', + isDraft: false, + createdAt: new Date(), + updatedAt: new Date() + } as BlogPost + ]); + + expect(views).toEqual([ + { + id: 'post-1', + title: 'Hello', + thumbnailUrl: '/uploads/public/blog/images/thumb.webp', + detailHref: '/shop/blog/post-1' + } + ]); + }); +}); diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts new file mode 100644 index 0000000..84019e8 --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { getBlogImagePublicUrl } from '../../../config/uploadPaths'; +import { BlogPost } from '../../blog/entities/BlogPost'; +import type { StorefrontBlogPostCardView } from '../types/StorefrontBlogPostCardView'; +import type { StorefrontBlogPostDetailView } from '../types/StorefrontBlogPostDetailView'; + +@Injectable() +export class StorefrontBlogViewService { + toPostsIndexView(posts: BlogPost[]): StorefrontBlogPostCardView[] { + return posts.map(post => ({ + id: post.id, + title: post.title, + thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null, + detailHref: `/shop/blog/${post.id}` + })); + } + + toPostDetailView(post: BlogPost): StorefrontBlogPostDetailView { + return { + id: post.id, + title: post.title, + bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null, + thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null, + contentHtml: post.contentHtml + }; + } +} diff --git a/backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts new file mode 100644 index 0000000..95b940d --- /dev/null +++ b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts @@ -0,0 +1,6 @@ +export interface StorefrontBlogPostCardView { + id: string; + title: string; + thumbnailUrl: string | null; + detailHref: string; +} diff --git a/backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts new file mode 100644 index 0000000..5b00505 --- /dev/null +++ b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts @@ -0,0 +1,7 @@ +export interface StorefrontBlogPostDetailView { + id: string; + title: string; + bannerUrl: string | null; + thumbnailUrl: string | null; + contentHtml: string; +} diff --git a/backend/src/modules/storefrontCore/public/css/storefront.css b/backend/src/modules/storefrontCore/public/css/storefront.css index 874c72b..07b86b1 100644 --- a/backend/src/modules/storefrontCore/public/css/storefront.css +++ b/backend/src/modules/storefrontCore/public/css/storefront.css @@ -1598,3 +1598,64 @@ a:hover { font-size: 2rem; margin: 0 0 var(--sf-space-2); } + +.sf-blog-grid { + display: grid; + gap: var(--sf-space-3); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (min-width: 36rem) { + .sf-blog-grid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-product-card-min)), 1fr)); + } +} + +.sf-blog-card { + width: 100%; + max-width: var(--sf-product-card-max); + min-width: 0; + justify-self: start; +} + +.sf-blog-card__link { + display: flex; + flex-direction: column; + gap: var(--sf-space-2); + color: inherit; + text-decoration: none; +} + +.sf-blog-card__image { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: var(--sf-radius-sm); + background: var(--sf-surface-muted); +} + +.sf-blog-card__title { + font-weight: 600; +} + +.sf-blog-detail__banner { + width: 100vw; + margin-left: calc(50% - 50vw); + margin-bottom: var(--sf-space-4); + background: var(--sf-surface-muted); +} + +.sf-blog-detail__banner-image { + display: block; + width: 100%; + max-height: 24rem; + object-fit: cover; +} + +.sf-blog-detail__title { + margin-bottom: var(--sf-space-4); +} + +.sf-blog-detail__content { + max-width: 42rem; +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts index 80b877c..62b119a 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -137,7 +137,8 @@ describe('StorefrontShopViewService', () => { logoUrl: '/uploads/logo.png', faviconUrl: '/uploads/favicon.ico', simplexLink: 'https://simplex.example', - shippingNote: 'Ships in 3 days' + shippingNote: 'Ships in 3 days', + blogEnabled: true }, fiatPerXmr: 200, fiatPerBtc: 80_000 @@ -158,6 +159,7 @@ describe('StorefrontShopViewService', () => { faviconUrl: '/uploads/favicon.ico', simplexLink: 'https://simplex.example', shippingNote: 'Ships in 3 days', + blogEnabled: true, themePreference: 'dark' }); }); diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts index f236966..56ae5ba 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts @@ -51,7 +51,7 @@ export class StorefrontShopViewService { const cryptoRates = this.buildCryptoRates(enabledPaymentMethods); - const { logoUrl, faviconUrl, simplexLink, shippingNote } = + const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } = await this.shopSettingsService.getStorefrontBranding(); const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res); @@ -75,6 +75,7 @@ export class StorefrontShopViewService { faviconUrl, simplexLink, shippingNote, + blogEnabled, themePreference, pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page) }; diff --git a/backend/src/modules/storefrontCore/types/ShopNavKey.ts b/backend/src/modules/storefrontCore/types/ShopNavKey.ts index ce92918..89e11ad 100644 --- a/backend/src/modules/storefrontCore/types/ShopNavKey.ts +++ b/backend/src/modules/storefrontCore/types/ShopNavKey.ts @@ -1 +1 @@ -export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order'; +export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order'; diff --git a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts index d04e7bb..8c1c952 100644 --- a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts +++ b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts @@ -20,6 +20,7 @@ export type ShopRenderLocals = { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; + blogEnabled: boolean; themePreference: StorefrontThemePreference; pageMeta: StorefrontPageMeta; }; diff --git a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts index 5c7eae1..1ee01b3 100644 --- a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts +++ b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts @@ -33,6 +33,7 @@ const STOREFRONT_PARTIALS = [ { name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' }, { name: 'page-back-link', file: 'page-back-link.hbs' }, { name: 'product-card', file: 'product-card.hbs' }, + { name: 'blog-card', file: 'blog-card.hbs' }, { name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' }, { name: 'category-nav', file: 'category-nav.hbs' }, { name: 'product-delivery-note', file: 'product-delivery-note.hbs' }, diff --git a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts index 7182ed6..bc0f5e3 100644 --- a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts +++ b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts @@ -29,6 +29,11 @@ describe('resolveShopNavActive', () => { }); }); + it('highlights blog pages', () => { + expect(resolveShopNavActive('/shop/blog')).toEqual({ activeShopNav: 'blog', activeOrderId: null }); + expect(resolveShopNavActive('/shop/blog/post-1')).toEqual({ activeShopNav: 'blog', activeOrderId: null }); + }); + it('does not highlight nav items on unrelated pages', () => { expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null }); }); diff --git a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts index c24859c..026b01e 100644 --- a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts +++ b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts @@ -23,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => { return { activeShopNav: null, activeOrderId: third }; } + if (first === 'shop' && second === 'blog') { + return { activeShopNav: 'blog', activeOrderId: null }; + } + if (first === 'shop' && (second === 'categories' || second === 'products')) { return { activeShopNav: 'shop', activeOrderId: null }; } diff --git a/backend/src/modules/storefrontCore/views/blog-detail.hbs b/backend/src/modules/storefrontCore/views/blog-detail.hbs new file mode 100644 index 0000000..86ed60b --- /dev/null +++ b/backend/src/modules/storefrontCore/views/blog-detail.hbs @@ -0,0 +1,16 @@ +{{> page-back-link href=backHref label=backLabel}} + +{{#if post.bannerUrl}} +
+ {{> storefront-image + class='sf-blog-detail__banner-image' + src=post.bannerUrl + width='1600' + height='600' + }} +
+{{/if}} + +

{{post.title}}

+ +
{{{post.contentHtml}}}
diff --git a/backend/src/modules/storefrontCore/views/blog-index.hbs b/backend/src/modules/storefrontCore/views/blog-index.hbs new file mode 100644 index 0000000..e2f93d4 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/blog-index.hbs @@ -0,0 +1,11 @@ +

{{title}}

+ +{{#if posts.length}} +
    + {{#each posts}} + {{> blog-card}} + {{/each}} +
+{{else}} +

No posts yet.

+{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/blog-card.hbs b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs new file mode 100644 index 0000000..dfad2ea --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs @@ -0,0 +1,14 @@ +
  • + + {{#if thumbnailUrl}} + {{> storefront-image + class='sf-blog-card__image' + src=thumbnailUrl + width='1' + height='1' + loading='lazy' + }} + {{/if}} + {{title}} + +
  • diff --git a/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs b/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs index 7e67148..edfa52b 100644 --- a/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs +++ b/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs @@ -17,6 +17,9 @@