import { Injectable, Inject, NotFoundException, ConflictException, Logger, OnApplicationBootstrap, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { BlogPost, BlogPostStatus } from '@domain/entities/blog-post.entity'; import { BlogPostRepository, BlogPostFilters, BLOG_POST_REPOSITORY, } from '@domain/ports/out/blog-post.repository'; import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; @Injectable() export class BlogService implements OnApplicationBootstrap { private readonly logger = new Logger(BlogService.name); constructor( @Inject(BLOG_POST_REPOSITORY) private readonly blogPostRepository: BlogPostRepository, @Inject(STORAGE_PORT) private readonly storage: StoragePort ) {} async onApplicationBootstrap(): Promise { try { await this.storage.ensureBucket(BLOG_IMAGES_BUCKET); this.logger.log(`Blog images bucket "${BLOG_IMAGES_BUCKET}" is ready`); } catch (err: any) { this.logger.warn(`Could not ensure blog images bucket: ${err?.message}`); } } async createPost(dto: CreateBlogPostDto): Promise { const slugTaken = await this.blogPostRepository.slugExists(dto.slug); if (slugTaken) { throw new ConflictException(`Slug "${dto.slug}" is already in use`); } let post = BlogPost.create({ id: uuidv4(), title: dto.title, slug: dto.slug, excerpt: dto.excerpt, content: dto.content, coverImageUrl: dto.coverImageUrl, category: dto.category, tags: dto.tags ?? [], authorName: dto.authorName, metaTitle: dto.metaTitle, metaDescription: dto.metaDescription, primaryKeyword: dto.primaryKeyword, secondaryKeywords: dto.secondaryKeywords ?? [], aiSummary: dto.aiSummary, faq: dto.faq ?? [], keyTakeaways: dto.keyTakeaways ?? [], aiEntities: dto.aiEntities ?? [], }); if (dto.scheduledAt) { const schedDate = new Date(dto.scheduledAt); post = schedDate > new Date() ? post.schedule(schedDate) : post.publish(); } return this.blogPostRepository.save(post); } async updatePost(id: string, dto: UpdateBlogPostDto): Promise { const post = await this.findOrFail(id); if (dto.slug && dto.slug !== post.slug) { const slugTaken = await this.blogPostRepository.slugExists(dto.slug, id); if (slugTaken) { throw new ConflictException(`Slug "${dto.slug}" is already in use`); } } let updated = post.update({ title: dto.title, slug: dto.slug, excerpt: dto.excerpt, content: dto.content, coverImageUrl: dto.coverImageUrl, category: dto.category, tags: dto.tags, authorName: dto.authorName, isFeatured: dto.isFeatured, metaTitle: dto.metaTitle, metaDescription: dto.metaDescription, primaryKeyword: dto.primaryKeyword, secondaryKeywords: dto.secondaryKeywords, aiSummary: dto.aiSummary, faq: dto.faq, keyTakeaways: dto.keyTakeaways, aiEntities: dto.aiEntities, }); if (dto.scheduledAt) { const schedDate = new Date(dto.scheduledAt); updated = schedDate > new Date() ? updated.schedule(schedDate) : updated.publish(); } else if (dto.status !== undefined && dto.status !== post.status) { if (dto.status === BlogPostStatus.PUBLISHED) updated = updated.publish(); else if (dto.status === BlogPostStatus.ARCHIVED) updated = updated.archive(); else if (dto.status === BlogPostStatus.DRAFT) updated = updated.unpublish(); else if (dto.status === BlogPostStatus.SCHEDULED && post.publishedAt) updated = updated.schedule(post.publishedAt); } return this.blogPostRepository.save(updated); } /** * Soft-delete a post: move it to the trash. It can be restored later or * permanently deleted from the trash. */ async deletePost(id: string): Promise { const post = await this.findOrFail(id); await this.blogPostRepository.save(post.trash()); } /** Restore a post from the trash. */ async restorePost(id: string): Promise { const post = await this.findOrFail(id); return this.blogPostRepository.save(post.restore()); } /** * Permanently delete a post (hard delete). Also removes its cover image from * storage when it is a locally hosted blog image (best-effort). */ async permanentlyDeletePost(id: string): Promise { const post = await this.findOrFail(id); const coverUrl = post.coverImageUrl; if (coverUrl) { const match = coverUrl.match(/\/api\/v1\/blog\/images\/([^/?#]+)$/); if (match) { try { await this.storage.delete({ bucket: BLOG_IMAGES_BUCKET, key: `blog-images/${match[1]}` }); } catch (err: any) { this.logger.warn(`Could not delete cover image for post ${id}: ${err?.message}`); } } } await this.blogPostRepository.delete(id); } /** * Duplicate a post as a new draft with a unique slug. */ async duplicatePost(id: string): Promise { const source = await this.findOrFail(id); const slug = await this.generateUniqueSlug(source.slug); const copy = BlogPost.create({ id: uuidv4(), title: `${source.title} (copie)`, slug, excerpt: source.excerpt, content: source.content, coverImageUrl: source.coverImageUrl, category: source.category, tags: [...source.tags], authorName: source.authorName, metaTitle: source.metaTitle, metaDescription: source.metaDescription, primaryKeyword: source.primaryKeyword, secondaryKeywords: [...source.secondaryKeywords], aiSummary: source.aiSummary, faq: source.faq.map(item => ({ ...item })), keyTakeaways: [...source.keyTakeaways], aiEntities: [...source.aiEntities], }); return this.blogPostRepository.save(copy); } private async generateUniqueSlug(baseSlug: string): Promise { let candidate = `${baseSlug}-copie`; let counter = 2; while (await this.blogPostRepository.slugExists(candidate)) { candidate = `${baseSlug}-copie-${counter}`; counter += 1; } return candidate; } async getPostById(id: string): Promise { return this.findOrFail(id); } async getPublishedPostBySlug(slug: string): Promise { const post = await this.blogPostRepository.findBySlug(slug); if (!post || !post.isVisibleToPublic()) { throw new NotFoundException('Article not found'); } return post; } async listPublishedPosts( filters: BlogPostFilters ): Promise<{ posts: BlogPost[]; total: number }> { const publishedFilters: BlogPostFilters = { ...filters, status: BlogPostStatus.PUBLISHED, includeScheduled: true, }; const [posts, total] = await Promise.all([ this.blogPostRepository.findByFilters(publishedFilters), this.blogPostRepository.count(publishedFilters), ]); return { posts, total }; } async listAllPosts(filters: BlogPostFilters): Promise<{ posts: BlogPost[]; total: number }> { const [posts, total] = await Promise.all([ this.blogPostRepository.findByFilters(filters), this.blogPostRepository.count(filters), ]); return { posts, total }; } private async findOrFail(id: string): Promise { const post = await this.blogPostRepository.findById(id); if (!post) { throw new NotFoundException(`Blog post with id "${id}" not found`); } return post; } }