import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { lastValueFrom } from 'rxjs'; import { ArticlesService } from './articles.service'; import { LiveBlogService } from './live-blog.service'; import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto'; import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition, } from './entities'; interface StrapiImage { url: string; alternativeText?: string; caption?: string; width?: number; height?: number; formats?: Record; } interface StrapiImageFormat { url: string; width: number; height: number; } interface StrapiArticle { id: number; documentId: string; title: string; description: string; content: string; slug: string; publishedAt: string | null; createdAt: string; updatedAt: string; img?: StrapiImage; media?: StrapiImage[]; imagePosition?: string; imageSize?: string; videoUrl?: string; videoPosition?: string; videoCaption?: string; } interface StrapiLiveBlog { id: number; documentId: string; title: string; description: string; slug: string; status: 'draft' | 'live' | 'ended' | 'archived'; publishedAt: string | null; createdAt: string; updatedAt: string; img?: StrapiImage; media?: StrapiImage[]; imagePosition?: string; imageSize?: string; videoUrl?: string; videoPosition?: string; videoCaption?: string; } interface StrapiResponse { data: T; meta: { pagination: { page: number; pageSize: number; pageCount: number; total: number; }; }; } @Injectable() export class StrapiService { private readonly logger = new Logger(StrapiService.name); private readonly strapiUrl: string; private readonly strapiApiToken: string; constructor( private readonly configService: ConfigService, private readonly httpService: HttpService, @Inject(forwardRef(() => ArticlesService)) private readonly articlesService: ArticlesService, @Inject(forwardRef(() => LiveBlogService)) private readonly liveBlogService: LiveBlogService, ) { this.strapiUrl = this.configService.get('STRAPI_URL') || 'http://localhost:1337'; this.strapiApiToken = this.configService.get('STRAPI_API_TOKEN') || ''; } private getHeaders() { const headers: Record = { 'Content-Type': 'application/json', }; if (this.strapiApiToken) { headers['Authorization'] = `Bearer ${this.strapiApiToken}`; } return headers; } private extractImageUrl(strapiArticle: StrapiArticle): string | undefined { // Try to get image from img field first (single image) let imageUrl: string | undefined; if (strapiArticle.img?.url) { imageUrl = strapiArticle.img.url; } else if (strapiArticle.media?.[0]?.url) { // Try to get first image from media field (multiple images) imageUrl = strapiArticle.media[0].url; } if (!imageUrl) { return undefined; } // If URL is relative, prepend Strapi base URL if (imageUrl.startsWith('/')) { // Convert Docker service URL to localhost for frontend access // Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337 const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:'); return `${frontendStrapiUrl}${imageUrl}`; } return imageUrl; } private extractLiveBlogImageUrl( strapiLiveBlog: StrapiLiveBlog, ): string | undefined { // Try to get image from img field first (single image) let imageUrl: string | undefined; if (strapiLiveBlog.img?.url) { imageUrl = strapiLiveBlog.img.url; } else if (strapiLiveBlog.media?.[0]?.url) { // Try to get first image from media field (multiple images) imageUrl = strapiLiveBlog.media[0].url; } if (!imageUrl) { return undefined; } // If URL is relative, prepend Strapi base URL if (imageUrl.startsWith('/')) { // Convert Docker service URL to localhost for frontend access // Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337 const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:'); return `${frontendStrapiUrl}${imageUrl}`; } return imageUrl; } async syncArticles(): Promise { try { this.logger.log('Starting articles sync from Strapi...'); const response = await lastValueFrom( this.httpService.get>( `${this.strapiUrl}/api/articles?populate=*`, { headers: this.getHeaders(), }, ), ); const strapiArticles = response.data.data; let syncedCount = 0; for (const strapiArticle of strapiArticles) { const imageUrl = this.extractImageUrl(strapiArticle); const articleData: Partial = { title: strapiArticle.title, excerpt: strapiArticle.description, content: strapiArticle.content, slug: strapiArticle.slug, status: strapiArticle.publishedAt ? ArticleStatus.PUBLISHED : ArticleStatus.DRAFT, tags: [], featuredImage: imageUrl, imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, videoUrl: strapiArticle.videoUrl || '', videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, videoCaption: strapiArticle.videoCaption || '', }; await this.articlesService.syncFromStrapi( strapiArticle.documentId, articleData, ); syncedCount++; } this.logger.log( `Successfully synced ${syncedCount} articles from Strapi`, ); } catch (error) { this.logger.error('Failed to sync articles from Strapi', error); throw error; } } async syncSingleArticle( strapiId: string, event?: | 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', ): Promise { try { this.logger.log( `Syncing single article from Strapi: ${strapiId}, event: ${event}`, ); const response = await lastValueFrom( this.httpService.get>( `${this.strapiUrl}/api/articles/${strapiId}?populate=*`, { headers: this.getHeaders(), }, ), ); const strapiArticle = response.data.data; // Determine status based on publishedAt and event type let status: ArticleStatus; if (event === 'entry.unpublish') { // If it's an unpublish event, always mark as draft status = ArticleStatus.DRAFT; } else if (strapiArticle.publishedAt) { // If publishedAt exists, it's published status = ArticleStatus.PUBLISHED; } else { // Otherwise it's a draft status = ArticleStatus.DRAFT; } const imageUrl = this.extractImageUrl(strapiArticle); const articleData: Partial = { title: strapiArticle.title, excerpt: strapiArticle.description, content: strapiArticle.content, slug: strapiArticle.slug, status, tags: [], featuredImage: imageUrl, imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, videoUrl: strapiArticle.videoUrl || '', videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, videoCaption: strapiArticle.videoCaption || '', }; await this.articlesService.syncFromStrapi( strapiArticle.documentId, articleData, ); this.logger.log( `Successfully synced article: ${strapiArticle.title} with status: ${status}`, ); } catch (error: unknown) { // If we get a 404 and it's an unpublish event, we can still mark it as draft const axiosError = error as { response?: { status: number } }; if (event === 'entry.unpublish' && axiosError.response?.status === 404) { this.logger.log( `Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`, ); // Try to update the article status to draft directly try { const articleData: Partial = { status: ArticleStatus.DRAFT, }; await this.articlesService.syncFromStrapi(strapiId, articleData); this.logger.log( `Marked article ${strapiId} as draft based on unpublish event`, ); } catch (syncError) { this.logger.error( `Failed to mark article ${strapiId} as draft:`, syncError, ); throw syncError; } } else { this.logger.error( `Failed to sync article ${strapiId} from Strapi`, error, ); throw error; } } } async syncLiveBlogs(): Promise { try { this.logger.log('Starting live blogs sync from Strapi...'); const response = await lastValueFrom( this.httpService.get>( `${this.strapiUrl}/api/live-blogs?populate=*`, { headers: this.getHeaders(), }, ), ); const strapiLiveBlogs = response.data.data; let syncedCount = 0; for (const strapiLiveBlog of strapiLiveBlogs) { const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const liveBlogData: Partial = { title: strapiLiveBlog.title, description: strapiLiveBlog.description, slug: strapiLiveBlog.slug, status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status), featuredImage: imageUrl, imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, videoUrl: strapiLiveBlog.videoUrl || '', videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, videoCaption: strapiLiveBlog.videoCaption || '', }; await this.liveBlogService.syncFromStrapi( strapiLiveBlog.documentId, liveBlogData, ); syncedCount++; } this.logger.log( `Successfully synced ${syncedCount} live blogs from Strapi`, ); } catch (error) { this.logger.error('Failed to sync live blogs from Strapi', error); throw error; } } async syncSingleLiveBlog( strapiId: string, event?: | 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', ): Promise { try { this.logger.log( `Syncing single live blog from Strapi: ${strapiId}, event: ${event}`, ); const response = await lastValueFrom( this.httpService.get>( `${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`, { headers: this.getHeaders(), }, ), ); const strapiLiveBlog = response.data.data; // For live blogs, we use the status from Strapi directly // but we might want to handle unpublish events differently let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status); // If it's an unpublish event, set to draft if (event === 'entry.unpublish') { status = LiveBlogStatus.DRAFT; } const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); const liveBlogData: Partial = { title: strapiLiveBlog.title, description: strapiLiveBlog.description, slug: strapiLiveBlog.slug, status, featuredImage: imageUrl, imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, videoUrl: strapiLiveBlog.videoUrl || '', videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, videoCaption: strapiLiveBlog.videoCaption || '', }; await this.liveBlogService.syncFromStrapi( strapiLiveBlog.documentId, liveBlogData, ); this.logger.log( `Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`, ); } catch (error: unknown) { // If we get a 404 and it's an unpublish event, we can still mark it as draft const axiosError = error as { response?: { status: number } }; if (event === 'entry.unpublish' && axiosError.response?.status === 404) { this.logger.log( `Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`, ); // Try to update the live blog status to draft directly try { const liveBlogData: Partial = { status: LiveBlogStatus.DRAFT, }; await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData); this.logger.log( `Marked live blog ${strapiId} as draft based on unpublish event`, ); } catch (syncError) { this.logger.error( `Failed to mark live blog ${strapiId} as draft:`, syncError, ); throw syncError; } } else { this.logger.error( `Failed to sync live blog ${strapiId} from Strapi`, error, ); throw error; } } } private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus { switch (status) { case 'live': return LiveBlogStatus.LIVE; case 'draft': return LiveBlogStatus.DRAFT; case 'ended': return LiveBlogStatus.ENDED; case 'archived': return LiveBlogStatus.ARCHIVED; default: return LiveBlogStatus.DRAFT; } } async handleWebhook( event: | 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', data: { documentId: string; model?: string }, ): Promise { this.logger.log( `Received webhook event: ${event} for model: ${data.model}`, ); if (event === 'entry.delete') { this.logger.log( `Handling delete for document: ${data.documentId}, model: ${data.model}`, ); if (data.model === 'article') { await this.articlesService.removeByStrapiId(data.documentId); } else if (data.model === 'live-blog') { await this.liveBlogService.removeByStrapiId(data.documentId); } else { this.logger.warn(`Cannot delete: unknown model type: ${data.model}`); } return; } // Route to appropriate sync method based on model if (data.model === 'article') { await this.syncSingleArticle(data.documentId, event); } else if (data.model === 'live-blog') { await this.syncSingleLiveBlog(data.documentId, event); } else { this.logger.warn(`Unknown model type in webhook: ${data.model}`); } } async updateArticleStatusInStrapi( strapiId: string, status: ArticleStatus, ): Promise { try { this.logger.log( `Updating article ${strapiId} status in Strapi to: ${status}`, ); // Map our status to Strapi status let strapiStatus: string; switch (status) { case ArticleStatus.DRAFT: strapiStatus = 'draft'; break; case ArticleStatus.PUBLISHED: strapiStatus = 'published'; break; case ArticleStatus.ARCHIVED: strapiStatus = 'archived'; break; default: strapiStatus = 'draft'; } // For archived status, we need to unpublish first if it's published if (status === ArticleStatus.ARCHIVED) { await lastValueFrom( this.httpService.patch( `${this.strapiUrl}/api/articles/${strapiId}/unpublish`, {}, { headers: this.getHeaders() }, ), ); } // Update the status await lastValueFrom( this.httpService.patch( `${this.strapiUrl}/api/articles/${strapiId}`, { data: { status: strapiStatus } }, { headers: this.getHeaders() }, ), ); this.logger.log( `Successfully updated article ${strapiId} status in Strapi to: ${status}`, ); } catch (error) { this.logger.error( `Failed to update article ${strapiId} status in Strapi:`, error, ); throw error; } } async updateLiveBlogStatusInStrapi( strapiId: string, status: LiveBlogStatus, ): Promise { try { this.logger.log( `Updating live blog ${strapiId} status in Strapi to: ${status}`, ); // Map our status to Strapi status let strapiStatus: string; switch (status) { case LiveBlogStatus.DRAFT: strapiStatus = 'draft'; break; case LiveBlogStatus.LIVE: strapiStatus = 'live'; break; case LiveBlogStatus.ENDED: strapiStatus = 'ended'; break; case LiveBlogStatus.ARCHIVED: strapiStatus = 'archived'; break; default: strapiStatus = 'draft'; } // For archived status, we need to unpublish first if it's published if (status === LiveBlogStatus.ARCHIVED) { await lastValueFrom( this.httpService.patch( `${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`, {}, { headers: this.getHeaders() }, ), ); } // Update the status await lastValueFrom( this.httpService.patch( `${this.strapiUrl}/api/live-blogs/${strapiId}`, { data: { status: strapiStatus } }, { headers: this.getHeaders() }, ), ); this.logger.log( `Successfully updated live blog ${strapiId} status in Strapi to: ${status}`, ); } catch (error) { this.logger.error( `Failed to update live blog ${strapiId} status in Strapi:`, error, ); throw error; } } async deleteArticleFromStrapi(strapiId: string): Promise { try { this.logger.log(`Deleting article ${strapiId} from Strapi`); await lastValueFrom( this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, { headers: this.getHeaders(), }), ); this.logger.log(`Successfully deleted article ${strapiId} from Strapi`); } catch (error) { this.logger.error( `Failed to delete article ${strapiId} from Strapi:`, error, ); throw error; } } async deleteLiveBlogFromStrapi(strapiId: string): Promise { try { this.logger.log(`Deleting live blog ${strapiId} from Strapi`); await lastValueFrom( this.httpService.delete( `${this.strapiUrl}/api/live-blogs/${strapiId}`, { headers: this.getHeaders() }, ), ); this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`); } catch (error) { this.logger.error( `Failed to delete live blog ${strapiId} from Strapi:`, error, ); throw error; } } }