670 lines
19 KiB
TypeScript
670 lines
19 KiB
TypeScript
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<string, StrapiImageFormat>;
|
|
}
|
|
|
|
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<T> {
|
|
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<string>('STRAPI_URL') || 'http://localhost:1337';
|
|
this.strapiApiToken =
|
|
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
|
}
|
|
|
|
private getHeaders() {
|
|
const headers: Record<string, string> = {
|
|
'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<void> {
|
|
try {
|
|
this.logger.log('Starting articles sync from Strapi...');
|
|
|
|
const response = await lastValueFrom(
|
|
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
|
`${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<CreateArticleDto> = {
|
|
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<void> {
|
|
try {
|
|
this.logger.log(
|
|
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
|
|
);
|
|
|
|
const response = await lastValueFrom(
|
|
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
|
`${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<CreateArticleDto> = {
|
|
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<CreateArticleDto> = {
|
|
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<void> {
|
|
try {
|
|
this.logger.log('Starting live blogs sync from Strapi...');
|
|
|
|
const response = await lastValueFrom(
|
|
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
|
`${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<CreateLiveBlogDto> = {
|
|
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<void> {
|
|
try {
|
|
this.logger.log(
|
|
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
|
|
);
|
|
|
|
const response = await lastValueFrom(
|
|
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
|
`${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<CreateLiveBlogDto> = {
|
|
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<CreateLiveBlogDto> = {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|
|
}
|