diff --git a/backend/src/modules/articles.controller.ts b/backend/src/modules/articles.controller.ts index 8862c17..2f0449d 100644 --- a/backend/src/modules/articles.controller.ts +++ b/backend/src/modules/articles.controller.ts @@ -63,10 +63,7 @@ export class ArticlesController { } @Patch(':id/publish') - publish( - @Param('id') id: string, - @Query('status') status?: ArticleStatus, - ) { + publish(@Param('id') id: string, @Query('status') status?: ArticleStatus) { return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED); } } diff --git a/backend/src/modules/articles.module.ts b/backend/src/modules/articles.module.ts index 5d9fec7..52a8d05 100644 --- a/backend/src/modules/articles.module.ts +++ b/backend/src/modules/articles.module.ts @@ -1,11 +1,15 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ArticlesService } from './articles.service'; import { ArticlesController } from './articles.controller'; import { Article, Author, Category } from './entities'; +import { StrapiModule } from './strapi.module'; @Module({ - imports: [TypeOrmModule.forFeature([Article, Author, Category])], + imports: [ + TypeOrmModule.forFeature([Article, Author, Category]), + forwardRef(() => StrapiModule), + ], controllers: [ArticlesController], providers: [ArticlesService], exports: [ArticlesService], diff --git a/backend/src/modules/articles.service.ts b/backend/src/modules/articles.service.ts index aa35b48..2b92bba 100644 --- a/backend/src/modules/articles.service.ts +++ b/backend/src/modules/articles.service.ts @@ -1,7 +1,14 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +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, @@ -15,6 +22,8 @@ export class ArticlesService { constructor( @InjectRepository(Article) private readonly articleRepository: Repository
, + @Inject(forwardRef(() => StrapiService)) + private readonly strapiService: StrapiService, ) {} async create(dto: CreateArticleDto): Promise
{ @@ -102,44 +111,173 @@ export class ArticlesService { async update(id: string, dto: UpdateArticleDto): Promise
{ const article = await this.findOne(id); + const oldStatus = article.status; Object.assign(article, dto); - return await this.articleRepository.save(article); + 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; - return await this.articleRepository.save(article); + 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
{ + async publish( + id: string, + status: ArticleStatus = ArticleStatus.PUBLISHED, + ): Promise
{ const article = await this.findOne(id); article.status = status; - return await this.articleRepository.save(article); + 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
{ - let article = await this.articleRepository.findOne({ where: { strapiId } }); - - if (!article) { - article = this.articleRepository.create({ - strapiId, - ...data, - status: data.status || ArticleStatus.DRAFT, + // 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 }, }); - } else { - Object.assign(article, data); - } - return await this.articleRepository.save(article); + 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 { diff --git a/backend/src/modules/entities.ts b/backend/src/modules/entities.ts index f9398c1..d8b3bf6 100644 --- a/backend/src/modules/entities.ts +++ b/backend/src/modules/entities.ts @@ -174,7 +174,7 @@ export class Article { @Column({ default: 0 }) views: number; - @Column({ nullable: true }) + @Column({ nullable: true, unique: true }) strapiId: string; @Column({ nullable: true }) @@ -221,7 +221,7 @@ export class LiveBlog { @Column({ default: false }) isPinned: boolean; - @Column({ nullable: true }) + @Column({ nullable: true, unique: true }) strapiId: string; @Column({ nullable: true }) diff --git a/backend/src/modules/live-blog.controller.ts b/backend/src/modules/live-blog.controller.ts index 4e66209..84f3e81 100644 --- a/backend/src/modules/live-blog.controller.ts +++ b/backend/src/modules/live-blog.controller.ts @@ -129,10 +129,7 @@ export class LiveBlogController { } @Patch(':id/publish') - publish( - @Param('id') id: string, - @Query('status') status?: LiveBlogStatus, - ) { + publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) { return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT); } diff --git a/backend/src/modules/live-blog.module.ts b/backend/src/modules/live-blog.module.ts index 9adaec0..f385afc 100644 --- a/backend/src/modules/live-blog.module.ts +++ b/backend/src/modules/live-blog.module.ts @@ -1,14 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { LiveBlogService } from './live-blog.service'; import { LiveBlogController } from './live-blog.controller'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; +import { StrapiModule } from './strapi.module'; @Module({ imports: [ TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), EventEmitterModule.forRoot(), + forwardRef(() => StrapiModule), ], controllers: [LiveBlogController], providers: [LiveBlogService], diff --git a/backend/src/modules/live-blog.service.ts b/backend/src/modules/live-blog.service.ts index 6d2c65f..4230882 100644 --- a/backend/src/modules/live-blog.service.ts +++ b/backend/src/modules/live-blog.service.ts @@ -3,12 +3,15 @@ import { NotFoundException, Logger, OnModuleInit, + Inject, + forwardRef, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Response } from 'express'; import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities'; +import { StrapiService } from './strapi.service'; import { CreateLiveBlogDto, UpdateLiveBlogDto, @@ -50,6 +53,8 @@ export class LiveBlogService implements OnModuleInit { @InjectRepository(LiveBlogUpdate) private readonly liveBlogUpdateRepository: Repository, private readonly eventEmitter: EventEmitter2, + @Inject(forwardRef(() => StrapiService)) + private readonly strapiService: StrapiService, ) {} onModuleInit() { @@ -248,6 +253,22 @@ export class LiveBlogService implements OnModuleInit { blogId: id, status: dto.status, }); + + // Update Strapi if live blog has strapiId + if (liveBlog.strapiId) { + try { + await this.strapiService.updateLiveBlogStatusInStrapi( + liveBlog.strapiId, + dto.status, + ); + } catch (error) { + // Log error but don't fail - backend status is updated + this.logger.error( + `Failed to update Strapi status for live blog ${id}:`, + error, + ); + } + } } return updatedBlog; @@ -255,19 +276,72 @@ export class LiveBlogService implements OnModuleInit { async remove(id: string): Promise { const liveBlog = await this.findOne(id); + + // Delete from Strapi if live blog has strapiId + if (liveBlog.strapiId) { + try { + await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId); + } catch (error) { + // Log error but don't fail - we still want to delete from backend + this.logger.error( + `Failed to delete live blog ${id} from Strapi:`, + error, + ); + } + } + await this.liveBlogRepository.remove(liveBlog); } async archive(id: string): Promise { const liveBlog = await this.findOne(id); liveBlog.status = LiveBlogStatus.ARCHIVED; - return await this.liveBlogRepository.save(liveBlog); + const savedLiveBlog = await this.liveBlogRepository.save(liveBlog); + + // Update Strapi if live blog has strapiId + if (liveBlog.strapiId) { + try { + await this.strapiService.updateLiveBlogStatusInStrapi( + liveBlog.strapiId, + LiveBlogStatus.ARCHIVED, + ); + } catch (error) { + // Log error but don't fail - backend status is updated + this.logger.error( + `Failed to update Strapi status for live blog ${id}:`, + error, + ); + } + } + + return savedLiveBlog; } - async publish(id: string, status: LiveBlogStatus = LiveBlogStatus.DRAFT): Promise { + async publish( + id: string, + status: LiveBlogStatus = LiveBlogStatus.DRAFT, + ): Promise { const liveBlog = await this.findOne(id); liveBlog.status = status; - return await this.liveBlogRepository.save(liveBlog); + const savedLiveBlog = await this.liveBlogRepository.save(liveBlog); + + // Update Strapi if live blog has strapiId + if (liveBlog.strapiId) { + try { + await this.strapiService.updateLiveBlogStatusInStrapi( + liveBlog.strapiId, + status, + ); + } catch (error) { + // Log error but don't fail - backend status is updated + this.logger.error( + `Failed to update Strapi status for live blog ${id}:`, + error, + ); + } + } + + return savedLiveBlog; } // Live Blog Update CRUD operations @@ -433,21 +507,75 @@ export class LiveBlogService implements OnModuleInit { strapiId: string, data: Partial, ): Promise { - let liveBlog = await this.liveBlogRepository.findOne({ - where: { strapiId }, - }); - - if (!liveBlog) { - liveBlog = this.liveBlogRepository.create({ - strapiId, - ...data, - status: data.status || LiveBlogStatus.DRAFT, + // Use upsert to handle race conditions and ensure uniqueness + try { + // First try to find existing live blog by strapiId + const liveBlog = await this.liveBlogRepository.findOne({ + where: { strapiId }, }); - } else { - Object.assign(liveBlog, data); - } - return await this.liveBlogRepository.save(liveBlog); + if (liveBlog) { + // Update existing live blog + const currentStatus = liveBlog.status; + Object.assign(liveBlog, data); + + // Preserve archived status if live blog is already archived in our system + // unless Strapi explicitly sends a different status + if ( + currentStatus === LiveBlogStatus.ARCHIVED && + data.status !== LiveBlogStatus.ARCHIVED + ) { + liveBlog.status = LiveBlogStatus.ARCHIVED; + } + + return await this.liveBlogRepository.save(liveBlog); + } else { + // Create new live blog + const newLiveBlog = this.liveBlogRepository.create({ + strapiId, + ...data, + status: data.status || LiveBlogStatus.DRAFT, + }); + + return await this.liveBlogRepository.save(newLiveBlog); + } + } 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 existingLiveBlog = await this.liveBlogRepository.findOne({ + where: { strapiId }, + }); + + if (existingLiveBlog) { + const currentStatus = existingLiveBlog.status; + Object.assign(existingLiveBlog, data); + + if ( + currentStatus === LiveBlogStatus.ARCHIVED && + data.status !== LiveBlogStatus.ARCHIVED + ) { + existingLiveBlog.status = LiveBlogStatus.ARCHIVED; + } + + return await this.liveBlogRepository.save(existingLiveBlog); + } + } + + // Re-throw other errors + throw error; + } } async removeByStrapiId(strapiId: string): Promise { diff --git a/backend/src/modules/strapi.module.ts b/backend/src/modules/strapi.module.ts index 307b5d8..fd209e3 100644 --- a/backend/src/modules/strapi.module.ts +++ b/backend/src/modules/strapi.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { StrapiService } from './strapi.service'; import { StrapiController } from './strapi.controller'; @@ -6,7 +6,11 @@ import { ArticlesModule } from './articles.module'; import { LiveBlogModule } from './live-blog.module'; @Module({ - imports: [HttpModule, ArticlesModule, LiveBlogModule], + imports: [ + HttpModule, + forwardRef(() => ArticlesModule), + forwardRef(() => LiveBlogModule), + ], controllers: [StrapiController], providers: [StrapiService], exports: [StrapiService], diff --git a/backend/src/modules/strapi.service.ts b/backend/src/modules/strapi.service.ts index 6a8d30a..ebb9986 100644 --- a/backend/src/modules/strapi.service.ts +++ b/backend/src/modules/strapi.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { lastValueFrom } from 'rxjs'; @@ -13,6 +13,21 @@ import { 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; @@ -23,8 +38,8 @@ interface StrapiArticle { publishedAt: string | null; createdAt: string; updatedAt: string; - img?: any; - media?: any[]; + img?: StrapiImage; + media?: StrapiImage[]; imagePosition?: string; imageSize?: string; videoUrl?: string; @@ -42,8 +57,8 @@ interface StrapiLiveBlog { publishedAt: string | null; createdAt: string; updatedAt: string; - img?: any; - media?: any[]; + img?: StrapiImage; + media?: StrapiImage[]; imagePosition?: string; imageSize?: string; videoUrl?: string; @@ -72,7 +87,9 @@ export class StrapiService { 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 = @@ -262,9 +279,10 @@ export class StrapiService { this.logger.log( `Successfully synced article: ${strapiArticle.title} with status: ${status}`, ); - } catch (error: any) { + } catch (error: unknown) { // If we get a 404 and it's an unpublish event, we can still mark it as draft - if (event === 'entry.unpublish' && error.response?.status === 404) { + 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`, ); @@ -403,9 +421,10 @@ export class StrapiService { this.logger.log( `Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`, ); - } catch (error: any) { + } catch (error: unknown) { // If we get a 404 and it's an unpublish event, we can still mark it as draft - if (event === 'entry.unpublish' && error.response?.status === 404) { + 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`, ); @@ -489,4 +508,162 @@ export class StrapiService { 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; + } + } } diff --git a/todos.md b/todos.md index f84b3a8..03845a2 100644 --- a/todos.md +++ b/todos.md @@ -1,5 +1,4 @@ 1. role based auth [admin, contributor, user] -2. [ ] video 3. social media integration [telegram, whatsup, facebook, viber] 4. share with functionality