import { Controller, Get, Param, Query, HttpCode, HttpStatus, Res, NotFoundException, Inject, Logger, StreamableFile, } from '@nestjs/common'; import { Response } from 'express'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; import { Public } from '../decorators/public.decorator'; import { BlogService } from '../services/blog.service'; import { BlogPost } from '@domain/entities/blog-post.entity'; import { BlogPostResponseDto, BlogPostListResponseDto } from '../dto/blog-post.dto'; import type { BlogPostCategory } from '@domain/entities/blog-post.entity'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; @ApiTags('Blog') @Controller('blog') @Public() export class BlogController { private readonly logger = new Logger(BlogController.name); constructor( private readonly blogService: BlogService, @Inject(STORAGE_PORT) private readonly storage: StoragePort ) {} @Get() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'List published blog posts' }) @ApiQuery({ name: 'category', required: false, enum: ['industry', 'technology', 'guides', 'news'], }) @ApiQuery({ name: 'search', required: false }) @ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'offset', required: false, type: Number }) @ApiResponse({ status: 200, type: BlogPostListResponseDto }) async listPosts( @Query('category') category?: BlogPostCategory, @Query('search') search?: string, @Query('limit') limit = 20, @Query('offset') offset = 0 ): Promise { const { posts, total } = await this.blogService.listPublishedPosts({ category, search, limit: Number(limit), offset: Number(offset), }); return { posts: posts.map(this.mapToDto), total, limit: Number(limit), offset: Number(offset), }; } @Get('images/:filename') @ApiOperation({ summary: 'Serve a blog image from storage' }) @ApiParam({ name: 'filename' }) async serveImage( @Param('filename') filename: string, @Res({ passthrough: true }) res: Response ): Promise { const key = `blog-images/${filename}`; let buffer: Buffer; try { buffer = await this.storage.download({ bucket: BLOG_IMAGES_BUCKET, key }); } catch (err: any) { this.logger.error(`Failed to serve blog image "${key}": ${err?.message}`); throw new NotFoundException(`Image not found: ${filename}`); } const ext = filename.split('.').pop()?.toLowerCase() ?? ''; const contentTypeMap: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml', }; const contentType = contentTypeMap[ext] ?? 'application/octet-stream'; res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=3600'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); return new StreamableFile(buffer); } @Get(':slug') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get a published blog post by slug' }) @ApiParam({ name: 'slug' }) @ApiResponse({ status: 200, type: BlogPostResponseDto }) async getPost(@Param('slug') slug: string): Promise { const post = await this.blogService.getPublishedPostBySlug(slug); return this.mapToDto(post); } private mapToDto(post: BlogPost): BlogPostResponseDto { return { id: post.id, title: post.title, slug: post.slug, excerpt: post.excerpt, content: post.content, coverImageUrl: post.coverImageUrl, category: post.category, tags: post.tags, authorName: post.authorName, status: post.status, isFeatured: post.isFeatured, publishedAt: post.publishedAt, createdAt: post.createdAt, updatedAt: post.updatedAt, }; } }