import { Injectable, NotFoundException, Logger, Inject, forwardRef, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Article, ArticleStatus } from './entities'; import { StrapiService } from './strapi.service'; import { CreateArticleDto, UpdateArticleDto, FindArticlesDto, } from './articles.dto'; @Injectable() export class ArticlesService { private readonly logger = new Logger(ArticlesService.name); constructor( @InjectRepository(Article) private readonly articleRepository: Repository
, @Inject(forwardRef(() => StrapiService)) private readonly strapiService: StrapiService, ) {} async create(dto: CreateArticleDto): Promise
{ const article = this.articleRepository.create({ ...dto, status: dto.status || ArticleStatus.DRAFT, }); return await this.articleRepository.save(article); } async findAll( dto: FindArticlesDto, ): Promise<{ data: Article[]; total: number }> { const { category, author, tag, status, search, page = 1, limit = 10 } = dto; const queryBuilder = this.articleRepository .createQueryBuilder('article') .leftJoinAndSelect('article.author', 'author') .leftJoinAndSelect('article.category', 'category'); // Handle status filter - can be single value or comma-separated list if (status) { if (typeof status === 'string' && status.includes(',')) { const statuses = status.split(',').map((s) => s.trim()); queryBuilder.where('article.status IN (:...statuses)', { statuses }); } else { queryBuilder.where('article.status = :status', { status }); } } // If no status specified, return all articles (for admin dashboard) if (category) { queryBuilder.andWhere('category.slug = :category', { category }); } if (author) { queryBuilder.andWhere('author.slug = :author', { author }); } if (tag) { queryBuilder.andWhere(':tag = ANY(article.tags)', { tag }); } if (search) { queryBuilder.andWhere( '(article.title ILIKE :search OR article.content ILIKE :search)', { search: `%${search}%` }, ); } const [data, total] = await queryBuilder .orderBy('article.createdAt', 'DESC') .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { data, total }; } async findOne(id: string): Promise
{ const article = await this.articleRepository.findOne({ where: { id }, relations: ['author', 'category'], }); if (!article) { throw new NotFoundException(`Article with ID ${id} not found`); } return article; } async findBySlug(slug: string): Promise
{ const article = await this.articleRepository.findOne({ where: { slug }, relations: ['author', 'category'], }); if (!article) { throw new NotFoundException(`Article with slug ${slug} not found`); } return article; } async update(id: string, dto: UpdateArticleDto): Promise
{ const article = await this.findOne(id); const oldStatus = article.status; Object.assign(article, dto); const savedArticle = await this.articleRepository.save(article); // Update Strapi if status changed and article has strapiId if ( dto.status !== undefined && dto.status !== oldStatus && article.strapiId ) { try { await this.strapiService.updateArticleStatusInStrapi( article.strapiId, dto.status, ); } catch (error) { // Log error but don't fail - backend status is updated this.logger.error( `Failed to update Strapi status for article ${id}:`, error, ); } } return savedArticle; } async remove(id: string): Promise { const article = await this.findOne(id); // Delete from Strapi if article has strapiId if (article.strapiId) { try { await this.strapiService.deleteArticleFromStrapi(article.strapiId); } catch (error) { // Log error but don't fail - we still want to delete from backend this.logger.error(`Failed to delete article ${id} from Strapi:`, error); } } await this.articleRepository.remove(article); } async archive(id: string): Promise
{ const article = await this.findOne(id); article.status = ArticleStatus.ARCHIVED; const savedArticle = await this.articleRepository.save(article); // Update Strapi if article has strapiId if (article.strapiId) { try { await this.strapiService.updateArticleStatusInStrapi( article.strapiId, ArticleStatus.ARCHIVED, ); } catch (error) { // Log error but don't fail - backend status is updated this.logger.error( `Failed to update Strapi status for article ${id}:`, error, ); } } return savedArticle; } async publish( id: string, status: ArticleStatus = ArticleStatus.PUBLISHED, ): Promise
{ const article = await this.findOne(id); article.status = status; const savedArticle = await this.articleRepository.save(article); // Update Strapi if article has strapiId if (article.strapiId) { try { await this.strapiService.updateArticleStatusInStrapi( article.strapiId, status, ); } catch (error) { // Log error but don't fail - backend status is updated this.logger.error( `Failed to update Strapi status for article ${id}:`, error, ); } } return savedArticle; } async syncFromStrapi( strapiId: string, data: Partial, ): Promise
{ // Use upsert to handle race conditions and ensure uniqueness try { // First try to find existing article by strapiId const article = await this.articleRepository.findOne({ where: { strapiId }, }); if (article) { // Update existing article const currentStatus = article.status; Object.assign(article, data); // Preserve archived status if article is already archived in our system // unless Strapi explicitly sends a different status if ( currentStatus === ArticleStatus.ARCHIVED && data.status !== ArticleStatus.ARCHIVED ) { article.status = ArticleStatus.ARCHIVED; } return await this.articleRepository.save(article); } else { // Create new article const newArticle = this.articleRepository.create({ strapiId, ...data, status: data.status || ArticleStatus.DRAFT, }); return await this.articleRepository.save(newArticle); } } catch (error: unknown) { // If we get a unique constraint violation, try to find and update again // This handles race conditions where two syncs happen simultaneously const dbError = error as { code?: string; constraint?: string }; if ( dbError.code === '23505' && dbError.constraint?.includes('strapiId') ) { this.logger.warn( `Race condition detected for strapiId ${strapiId}, retrying...`, ); // Wait a bit and retry await new Promise((resolve) => setTimeout(resolve, 100)); const existingArticle = await this.articleRepository.findOne({ where: { strapiId }, }); if (existingArticle) { const currentStatus = existingArticle.status; Object.assign(existingArticle, data); if ( currentStatus === ArticleStatus.ARCHIVED && data.status !== ArticleStatus.ARCHIVED ) { existingArticle.status = ArticleStatus.ARCHIVED; } return await this.articleRepository.save(existingArticle); } } // Re-throw other errors throw error; } } async removeByStrapiId(strapiId: string): Promise { const article = await this.articleRepository.findOne({ where: { strapiId }, }); if (!article) { this.logger.warn(`Article with strapiId ${strapiId} not found`); return; } await this.articleRepository.remove(article); this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`); } async findHeroArticle(): Promise
{ return this.articleRepository.findOne({ where: { isHero: true, status: ArticleStatus.PUBLISHED, }, relations: ['author', 'category'], order: { createdAt: 'DESC' }, }); } }