fix(bookings,blog): Phase 1 correctifs test utilisateur

- Telechargement docs transporteur: proxy de streaming API (fin des URLs
  presignees MinIO injoignables depuis l'exterieur)
- Documents: ajout de l'action "Supprimer" (endpoint DELETE deja existant)
- Blog: suppression reelle de l'image de couverture (null explicite bout en bout)
- Blog: le bouton Retour navigateur ferme l'editeur au lieu de quitter vers Logs
- Paiement: bouton Retour revient au contexte precedent (router.back)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-01 13:20:33 +02:00
parent 71436cb9c4
commit 19b96517d2
13 changed files with 362 additions and 43 deletions

View File

@ -117,7 +117,7 @@ export class BlogController {
slug: post.slug, slug: post.slug,
excerpt: post.excerpt, excerpt: post.excerpt,
content: post.content, content: post.content,
coverImageUrl: post.coverImageUrl, coverImageUrl: post.coverImageUrl ?? undefined,
category: post.category, category: post.category,
tags: post.tags, tags: post.tags,
authorName: post.authorName, authorName: post.authorName,

View File

@ -1,4 +1,14 @@
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common'; import {
Controller,
Get,
Post,
Param,
Query,
Body,
Res,
StreamableFile,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
@ -173,4 +183,78 @@ export class CsvBookingActionsController {
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> { async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
return this.csvBookingService.getDocumentsForCarrier(token); return this.csvBookingService.getDocumentsForCarrier(token);
} }
/**
* Download a single booking document, streamed through the API (PUBLIC - token-based).
*
* Password-protected bookings must use POST with the password in the body.
*
* POST /api/v1/csv-booking-actions/documents/:token/:documentId/download
*/
@Public()
@Post('documents/:token/:documentId/download')
@ApiOperation({
summary: 'Download a booking document with password (public)',
description:
'Streams a single booking document to the carrier. Applies the same access rules as the documents list (booking accepted + password when protected).',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiParam({ name: 'documentId', description: 'Document ID' })
@ApiBody({ type: VerifyDocumentAccessDto })
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
@ApiResponse({ status: 401, description: 'Invalid or missing password' })
@ApiResponse({ status: 404, description: 'Booking or document not found' })
async downloadBookingDocumentWithPassword(
@Param('token') token: string,
@Param('documentId') documentId: string,
@Body() dto: VerifyDocumentAccessDto,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
return this.streamDocument(token, documentId, dto?.password, res);
}
/**
* Download a single booking document (PUBLIC - token-based) - Legacy without password.
*
* GET /api/v1/csv-booking-actions/documents/:token/:documentId/download
*/
@Public()
@Get('documents/:token/:documentId/download')
@ApiOperation({
summary: 'Download a booking document (public) - Legacy',
description:
'Streams a single booking document for bookings without password protection. Protected bookings must use the POST variant.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiParam({ name: 'documentId', description: 'Document ID' })
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
@ApiResponse({ status: 401, description: 'Password required for this booking' })
@ApiResponse({ status: 404, description: 'Booking or document not found' })
async downloadBookingDocument(
@Param('token') token: string,
@Param('documentId') documentId: string,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
return this.streamDocument(token, documentId, undefined, res);
}
private async streamDocument(
token: string,
documentId: string,
password: string | undefined,
res: Response
): Promise<StreamableFile> {
const { buffer, fileName, mimeType } = await this.csvBookingService.streamDocumentForCarrier(
token,
documentId,
password
);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(fileName)}"`
);
return new StreamableFile(buffer);
}
} }

View File

@ -10,6 +10,7 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
Matches, Matches,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity'; import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
@ -121,11 +122,15 @@ export class UpdateBlogPostDto {
@IsString() @IsString()
content?: string; content?: string;
@ApiPropertyOptional() @ApiPropertyOptional({
nullable: true,
description: 'Cover image URL. Pass null to remove the current cover image.',
})
@IsOptional() @IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString() @IsString()
@MaxLength(500) @MaxLength(500)
coverImageUrl?: string; coverImageUrl?: string | null;
@ApiPropertyOptional({ enum: CATEGORIES }) @ApiPropertyOptional({ enum: CATEGORIES })
@IsOptional() @IsOptional()

View File

@ -735,20 +735,17 @@ export class CsvBookingService {
throw new NotFoundException('Réservation introuvable'); throw new NotFoundException('Réservation introuvable');
} }
// Generate signed URLs for all documents // Build proxy download URLs. The file is streamed through the API rather than
const documentsWithUrls = await Promise.all( // served via a presigned storage URL, whose internal host is unreachable from
booking.documents.map(async doc => { // the carrier's browser. The frontend prepends the public API base URL.
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath); const documentsWithUrls = booking.documents.map(doc => ({
return {
id: doc.id, id: doc.id,
type: doc.type, type: doc.type,
fileName: doc.fileName, fileName: doc.fileName,
mimeType: doc.mimeType, mimeType: doc.mimeType,
size: doc.size, size: doc.size,
downloadUrl: signedUrl, downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`,
}; }));
})
);
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
@ -795,12 +792,10 @@ export class CsvBookingService {
} }
/** /**
* Generate signed URL for a document file path * Extract the storage object key from a stored document file path.
* Handles raw keys, "bucket/key" paths and full URLs.
*/ */
private async generateSignedUrlForDocument(filePath: string): Promise<string> { private extractDocumentKey(filePath: string): string {
const bucket = 'xpeditis-documents';
// Extract key from the file path
let key = filePath; let key = filePath;
if (filePath.includes('xpeditis-documents/')) { if (filePath.includes('xpeditis-documents/')) {
key = filePath.split('xpeditis-documents/')[1]; key = filePath.split('xpeditis-documents/')[1];
@ -811,10 +806,65 @@ export class CsvBookingService {
key = key.replace('xpeditis-documents/', ''); key = key.replace('xpeditis-documents/', '');
} }
} }
return key;
}
// Generate signed URL with 1 hour expiration /**
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600); * Stream a booking document to the carrier through the API (public, token-based).
return signedUrl; *
* Downloads the file from storage and returns it as a buffer so the API can
* proxy it to the carrier's browser. This avoids handing out presigned storage
* URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside.
*
* Applies the same access rules as {@link getDocumentsForCarrier}: the booking
* must be ACCEPTED and, when protected, the correct password must be supplied.
*/
async streamDocumentForCarrier(
token: string,
documentId: string,
password?: string
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
if (ormBooking.status !== 'ACCEPTED') {
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
}
if (ormBooking.passwordHash) {
if (!password) {
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
}
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Mot de passe incorrect');
}
}
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Réservation introuvable');
}
const document = booking.documents.find(doc => doc.id === documentId);
if (!document) {
throw new NotFoundException('Document introuvable');
}
const bucket = 'xpeditis-documents';
const key = this.extractDocumentKey(document.filePath);
const buffer = await this.storageAdapter.download({ bucket, key });
return {
buffer,
fileName: document.fileName,
mimeType: document.mimeType || 'application/octet-stream',
};
} }
/** /**

View File

@ -13,7 +13,7 @@ interface BlogPostProps {
slug: string; slug: string;
excerpt: string; excerpt: string;
content: string; content: string;
coverImageUrl?: string; coverImageUrl?: string | null;
category: BlogPostCategory; category: BlogPostCategory;
tags: string[]; tags: string[];
authorName: string; authorName: string;
@ -64,7 +64,7 @@ export class BlogPost {
get content(): string { get content(): string {
return this.props.content; return this.props.content;
} }
get coverImageUrl(): string | undefined { get coverImageUrl(): string | null | undefined {
return this.props.coverImageUrl; return this.props.coverImageUrl;
} }
get category(): BlogPostCategory { get category(): BlogPostCategory {

View File

@ -20,7 +20,7 @@ export class BlogPostOrmEntity {
content: string; content: string;
@Column('varchar', { length: 500, nullable: true }) @Column('varchar', { length: 500, nullable: true })
cover_image_url?: string; cover_image_url?: string | null;
@Column('varchar', { length: 50 }) @Column('varchar', { length: 50 })
category: string; category: string;

View File

@ -29,6 +29,7 @@ import {
Clock, Clock,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ArrowLeft,
} from 'lucide-react'; } from 'lucide-react';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
@ -181,7 +182,9 @@ export default function AdminBlogPage() {
slug: formData.slug, slug: formData.slug,
excerpt: formData.excerpt, excerpt: formData.excerpt,
content: formData.content, content: formData.content,
coverImageUrl: formData.coverImageUrl || undefined, // Send explicit null (not undefined) when the cover was removed, so the
// backend clears the column instead of leaving the previous value.
coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null,
category: formData.category, category: formData.category,
tags, tags,
authorName: formData.authorName, authorName: formData.authorName,
@ -318,7 +321,9 @@ export default function AdminBlogPage() {
setShowEditModal(true); setShowEditModal(true);
}; };
const closeModal = () => { // Pure UI reset — does not touch browser history. Used by the popstate
// handler (the browser already popped our entry at that point).
const resetEditorState = () => {
setShowCreateModal(false); setShowCreateModal(false);
setShowEditModal(false); setShowEditModal(false);
setSelectedPost(null); setSelectedPost(null);
@ -328,6 +333,28 @@ export default function AdminBlogPage() {
setSeoOpen(false); setSeoOpen(false);
}; };
// Explicit close (X / Retour / after save): reset UI and pop the history
// entry we pushed when opening, so the browser Back button stays on /admin/blog.
const closeModal = () => {
resetEditorState();
if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) {
window.history.back();
}
};
// Keep the browser Back button inside the blog editor: pushing a history entry
// when the editor opens means Back closes the editor instead of navigating away
// (e.g. to the System Logs tab).
useEffect(() => {
const editorOpen = showCreateModal || showEditModal;
if (!editorOpen) return;
window.history.pushState({ xpBlogEditor: true }, '');
const handlePop = () => resetEditorState();
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showCreateModal, showEditModal]);
const handleTitleChange = (title: string) => { const handleTitleChange = (title: string) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@ -535,9 +562,19 @@ export default function AdminBlogPage() {
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto"> <div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
{/* Header */} {/* Header */}
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm"> <div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-3">
<button
type="button"
onClick={closeModal}
className="flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg px-3 py-1.5 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour
</button>
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`} {showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
</h2> </h2>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{showEditModal && ( {showEditModal && (
<select <select

View File

@ -277,7 +277,30 @@ export default function CarrierDocumentsPage() {
setDownloading(doc.id); setDownloading(doc.id);
try { try {
window.open(doc.downloadUrl, '_blank'); // Stream the document through the API instead of relying on a presigned
// storage URL (whose internal host is unreachable from outside).
const downloadUrl = `${apiUrl}/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`;
const usePassword = !!requirements?.requiresPassword && !!password;
const response = await fetch(downloadUrl, {
method: usePassword ? 'POST' : 'GET',
headers: usePassword ? { 'Content-Type': 'application/json' } : undefined,
body: usePassword ? JSON.stringify({ password }) : undefined,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = doc.fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(objectUrl);
} catch (err) { } catch (err) {
console.error('Error downloading document:', err); console.error('Error downloading document:', err);
alert(t('downloadError')); alert(t('downloadError'));

View File

@ -212,13 +212,14 @@ export default function PayCommissionPage() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{/* Back button */} {/* Back button returns to the previous context (list or booking detail)
instead of always jumping to the global list. */}
<button <button
onClick={() => router.push('/dashboard/bookings')} onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium" className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Retour aux bookings Retour
</button> </button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1> <h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>

View File

@ -3,7 +3,14 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings'; import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react'; import {
FileText,
Image as ImageIcon,
FileEdit,
FileSpreadsheet,
Paperclip,
Trash2,
} from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
@ -56,6 +63,10 @@ export default function UserDocumentsPage() {
const [replaceFile, setReplaceFile] = useState<File | null>(null); const [replaceFile, setReplaceFile] = useState<File | null>(null);
const replaceFileInputRef = useRef<HTMLInputElement>(null); const replaceFileInputRef = useRef<HTMLInputElement>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [documentToDelete, setDocumentToDelete] = useState<DocumentWithBooking | null>(null);
const [deletingFile, setDeletingFile] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null); const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const getQuoteNumber = (booking: CsvBookingResponse): string => { const getQuoteNumber = (booking: CsvBookingResponse): string => {
@ -355,6 +366,43 @@ export default function UserDocumentsPage() {
} }
}; };
const handleDeleteClick = (doc: DocumentWithBooking) => {
setOpenDropdownId(null);
setDocumentToDelete(doc);
setShowDeleteModal(true);
};
const handleCloseDeleteModal = () => {
setShowDeleteModal(false);
setDocumentToDelete(null);
};
const handleDeleteDocument = async () => {
if (!documentToDelete) return;
setDeletingFile(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToDelete.bookingId}/documents/${documentToDelete.id}`,
{ method: 'DELETE', credentials: 'include' }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || t('deleteDocument.errorMessage'));
}
alert(t('deleteDocument.successMessage'));
handleCloseDeleteModal();
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error deleting document:', err);
alert(`${t('deleteDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
} finally {
setDeletingFile(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
@ -611,6 +659,13 @@ export default function UserDocumentsPage() {
</svg> </svg>
{t('actions.replace')} {t('actions.replace')}
</button> </button>
<button
onClick={() => handleDeleteClick(doc)}
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-3" />
{t('actions.delete')}
</button>
</div> </div>
</div> </div>
)} )}
@ -1049,6 +1104,50 @@ export default function UserDocumentsPage() {
</div> </div>
</div> </div>
)} )}
{/* Delete Document Modal */}
{showDeleteModal && documentToDelete && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 py-8">
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseDeleteModal}
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">
{t('deleteDocument.title')}
</h3>
<p className="mt-2 text-sm text-gray-600">
{t('deleteDocument.message', { fileName: documentToDelete.fileName })}
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={handleCloseDeleteModal}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
{t('deleteDocument.cancel')}
</button>
<button
type="button"
onClick={handleDeleteDocument}
disabled={deletingFile}
className="px-6 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deletingFile ? t('deleteDocument.deleting') : t('deleteDocument.confirm')}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1524,7 +1524,8 @@
}, },
"actions": { "actions": {
"download": "Download", "download": "Download",
"replace": "Replace" "replace": "Replace",
"delete": "Delete"
}, },
"export": { "export": {
"fileName": "File name", "fileName": "File name",
@ -1566,6 +1567,15 @@
"errorMessage": "Error replacing document", "errorMessage": "Error replacing document",
"noFileError": "Please select a replacement file" "noFileError": "Please select a replacement file"
}, },
"deleteDocument": {
"title": "Delete document",
"message": "Are you sure you want to delete \"{fileName}\"? This action cannot be undone.",
"confirm": "Delete",
"deleting": "Deleting...",
"cancel": "Cancel",
"successMessage": "Document deleted successfully!",
"errorMessage": "Error deleting document"
},
"downloadError": "Error downloading document" "downloadError": "Error downloading document"
}, },
"wikiPages": { "wikiPages": {

View File

@ -1524,7 +1524,8 @@
}, },
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
"replace": "Remplacer" "replace": "Remplacer",
"delete": "Supprimer"
}, },
"export": { "export": {
"fileName": "Nom du fichier", "fileName": "Nom du fichier",
@ -1566,6 +1567,15 @@
"errorMessage": "Erreur lors du remplacement du document", "errorMessage": "Erreur lors du remplacement du document",
"noFileError": "Veuillez sélectionner un fichier de remplacement" "noFileError": "Veuillez sélectionner un fichier de remplacement"
}, },
"deleteDocument": {
"title": "Supprimer le document",
"message": "Êtes-vous sûr de vouloir supprimer « {fileName} » ? Cette action est irréversible.",
"confirm": "Supprimer",
"deleting": "Suppression en cours...",
"cancel": "Annuler",
"successMessage": "Document supprimé avec succès!",
"errorMessage": "Erreur lors de la suppression du document"
},
"downloadError": "Erreur lors du téléchargement du document" "downloadError": "Erreur lors du téléchargement du document"
}, },
"wikiPages": { "wikiPages": {

View File

@ -210,7 +210,7 @@ export interface CreateBlogPostRequest extends BlogPostSeoFields {
slug: string; slug: string;
excerpt: string; excerpt: string;
content: string; content: string;
coverImageUrl?: string; coverImageUrl?: string | null;
category: BlogPostCategory; category: BlogPostCategory;
tags?: string[]; tags?: string[];
authorName: string; authorName: string;
@ -221,7 +221,7 @@ export interface UpdateBlogPostRequest extends BlogPostSeoFields {
slug?: string; slug?: string;
excerpt?: string; excerpt?: string;
content?: string; content?: string;
coverImageUrl?: string; coverImageUrl?: string | null;
category?: BlogPostCategory; category?: BlogPostCategory;
tags?: string[]; tags?: string[];
authorName?: string; authorName?: string;