xpeditis2.0/apps/backend/src/application/services/blog.service.ts
David 8877265526 feat(blog): Phase 3 GEO/IA, corbeille, duplication, drag&drop, recadrage
- Bloc GEO/IA (resume IA, FAQ, points cles, entites) + rendu public
  "En bref"/"A retenir"/FAQ + JSON-LD Article & FAQPage.
- Corbeille: soft-delete (deleted_at), filtres Tous/Publies/Brouillons/
  Planifies/Corbeille, restauration + suppression definitive.
- Duplication d'un article (nouveau brouillon, slug unique).
- Drag & drop de l'image de couverture.
- Recadrage auto de la couverture en 16:9 (sharp) via endpoint dedie
  /admin/blog/cover-images.
- Migration AddGeoAndTrashToBlogPosts (ai_summary, faq, key_takeaways,
  ai_entities, deleted_at). Ajout dependance sharp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:44:35 +02:00

239 lines
7.5 KiB
TypeScript

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<void> {
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<BlogPost> {
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<BlogPost> {
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<void> {
const post = await this.findOrFail(id);
await this.blogPostRepository.save(post.trash());
}
/** Restore a post from the trash. */
async restorePost(id: string): Promise<BlogPost> {
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<void> {
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<BlogPost> {
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<string> {
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<BlogPost> {
return this.findOrFail(id);
}
async getPublishedPostBySlug(slug: string): Promise<BlogPost> {
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<BlogPost> {
const post = await this.blogPostRepository.findById(id);
if (!post) {
throw new NotFoundException(`Blog post with id "${id}" not found`);
}
return post;
}
}