xpeditis2.0/apps/backend/src/application/dto/blog-post.dto.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

274 lines
6.3 KiB
TypeScript

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsOptional,
IsArray,
IsBoolean,
IsEnum,
IsDateString,
MaxLength,
MinLength,
Matches,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
BlogPostStatus,
type BlogPostCategory,
type BlogFaqItem,
} from '@domain/entities/blog-post.entity';
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
export class BlogFaqItemDto implements BlogFaqItem {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(500)
question: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(4000)
answer: string;
}
/**
* GEO (Generative Engine Optimisation) fields, shared by create/update DTOs.
*/
export class BlogGeoFields {
@ApiPropertyOptional({ description: 'AI summary / TL;DR of the article' })
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
@MaxLength(2000)
aiSummary?: string | null;
@ApiPropertyOptional({ type: [BlogFaqItemDto], description: 'FAQ (question/answer pairs)' })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BlogFaqItemDto)
faq?: BlogFaqItemDto[];
@ApiPropertyOptional({ type: [String], description: 'Key takeaways' })
@IsOptional()
@IsArray()
@IsString({ each: true })
keyTakeaways?: string[];
@ApiPropertyOptional({ type: [String], description: 'Related entities / topics' })
@IsOptional()
@IsArray()
@IsString({ each: true })
aiEntities?: string[];
}
export class CreateBlogPostDto extends BlogGeoFields {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@ApiProperty({ description: 'URL-friendly slug, e.g. "my-article"' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase alphanumeric with hyphens',
})
slug: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MinLength(10)
excerpt: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
content: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
coverImageUrl?: string;
@ApiProperty({ enum: CATEGORIES })
@IsEnum(CATEGORIES)
category: BlogPostCategory;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(255)
authorName: string;
@ApiPropertyOptional({ description: 'ISO date string for scheduled publication' })
@IsOptional()
@IsDateString()
scheduledAt?: string;
@ApiPropertyOptional({ description: 'SEO meta title (50-60 chars recommended)' })
@IsOptional()
@IsString()
@MaxLength(255)
metaTitle?: string;
@ApiPropertyOptional({ description: 'SEO meta description (150-160 chars recommended)' })
@IsOptional()
@IsString()
@MaxLength(500)
metaDescription?: string;
@ApiPropertyOptional({ description: 'Primary SEO keyword' })
@IsOptional()
@IsString()
@MaxLength(255)
primaryKeyword?: string;
@ApiPropertyOptional({ type: [String], description: 'Secondary SEO keywords' })
@IsOptional()
@IsArray()
@IsString({ each: true })
secondaryKeywords?: string[];
}
export class UpdateBlogPostDto extends BlogGeoFields {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(255)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase alphanumeric with hyphens',
})
slug?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
excerpt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
content?: string;
@ApiPropertyOptional({
nullable: true,
description: 'Cover image URL. Pass null to remove the current cover image.',
})
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
@MaxLength(500)
coverImageUrl?: string | null;
@ApiPropertyOptional({ enum: CATEGORIES })
@IsOptional()
@IsEnum(CATEGORIES)
category?: BlogPostCategory;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
authorName?: string;
@ApiPropertyOptional({ enum: BlogPostStatus })
@IsOptional()
@IsEnum(BlogPostStatus)
status?: BlogPostStatus;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isFeatured?: boolean;
@ApiPropertyOptional({ description: 'ISO date string for scheduled publication' })
@IsOptional()
@IsDateString()
scheduledAt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
metaTitle?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
metaDescription?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
primaryKeyword?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
secondaryKeywords?: string[];
}
export class BlogPostResponseDto {
@ApiProperty() id: string;
@ApiProperty() title: string;
@ApiProperty() slug: string;
@ApiProperty() excerpt: string;
@ApiProperty() content: string;
@ApiPropertyOptional() coverImageUrl?: string;
@ApiProperty() category: string;
@ApiProperty({ type: [String] }) tags: string[];
@ApiProperty() authorName: string;
@ApiProperty({ enum: BlogPostStatus }) status: BlogPostStatus;
@ApiProperty() isFeatured: boolean;
@ApiPropertyOptional() publishedAt?: Date;
@ApiPropertyOptional() metaTitle?: string;
@ApiPropertyOptional() metaDescription?: string;
@ApiPropertyOptional() primaryKeyword?: string;
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
@ApiPropertyOptional() aiSummary?: string | null;
@ApiProperty({ type: [BlogFaqItemDto] }) faq: BlogFaqItemDto[];
@ApiProperty({ type: [String] }) keyTakeaways: string[];
@ApiProperty({ type: [String] }) aiEntities: string[];
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
}
export class BlogPostListResponseDto {
@ApiProperty({ type: [BlogPostResponseDto] }) posts: BlogPostResponseDto[];
@ApiProperty() total: number;
@ApiProperty() limit: number;
@ApiProperty() offset: number;
}