- 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>
239 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
}
|