delete/archive implemented

This commit is contained in:
dimitar committed 2026-02-03 23:14:36 +01:00
1 parent 89b8687431
commit 9ca05f5ea1
13 files changed
+680 -163

No files matched your search

+2 -2
View File
@@ -15,11 +15,11 @@ async function bootstrap() {
});
app.setGlobalPrefix('api/v1');
const port = process.env.PORT ?? 3000;
const host = '0.0.0.0'; // Bind to all interfaces for Docker
await app.listen(port, host);
console.log(`Application is running on: http://${host}:${port}`);
}
void bootstrap();
@@ -4,6 +4,7 @@ import {
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
@@ -15,6 +16,7 @@ import {
UpdateArticleDto,
FindArticlesDto,
} from './articles.dto';
import { ArticleStatus } from './entities';
@Controller('articles')
export class ArticlesController {
@@ -54,4 +56,17 @@ export class ArticlesController {
remove(@Param('id') id: string) {
return this.articlesService.remove(id);
}
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.articlesService.archive(id);
}
@Patch(':id/publish')
publish(
@Param('id') id: string,
@Query('status') status?: ArticleStatus,
) {
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
}
}
+9 -3
View File
@@ -8,7 +8,13 @@ import {
IsBoolean,
IsDate,
} from 'class-validator';
import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
@@ -148,8 +154,8 @@ export class FindArticlesDto {
tag?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsString()
status?: string;
@IsOptional()
@IsString()
+29 -13
View File
@@ -28,21 +28,23 @@ export class ArticlesService {
async findAll(
dto: FindArticlesDto,
): Promise<{ data: Article[]; total: number }> {
const {
category,
author,
tag,
status = ArticleStatus.PUBLISHED,
search,
page = 1,
limit = 10,
} = dto;
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')
.where('article.status = :status', { status });
.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 });
@@ -109,6 +111,18 @@ export class ArticlesService {
await this.articleRepository.remove(article);
}
async archive(id: string): Promise<Article> {
const article = await this.findOne(id);
article.status = ArticleStatus.ARCHIVED;
return await this.articleRepository.save(article);
}
async publish(id: string, status: ArticleStatus = ArticleStatus.PUBLISHED): Promise<Article> {
const article = await this.findOne(id);
article.status = status;
return await this.articleRepository.save(article);
}
async syncFromStrapi(
strapiId: string,
data: Partial<CreateArticleDto>,
@@ -129,8 +143,10 @@ export class ArticlesService {
}
async removeByStrapiId(strapiId: string): Promise<void> {
const article = await this.articleRepository.findOne({ where: { strapiId } });
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) {
this.logger.warn(`Article with strapiId ${strapiId} not found`);
return;
@@ -4,6 +4,7 @@ import {
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
@@ -21,6 +22,7 @@ import {
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
import { LiveBlogStatus } from './entities';
@Controller('live-blogs')
export class LiveBlogController {
@@ -121,6 +123,19 @@ export class LiveBlogController {
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
}
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.liveBlogService.archive(id);
}
@Patch(':id/publish')
publish(
@Param('id') id: string,
@Query('status') status?: LiveBlogStatus,
) {
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
}
// SSE endpoint for real-time updates
@Get(':id/stream')
stream(
+22 -10
View File
@@ -116,13 +116,9 @@ export class LiveBlogService implements OnModuleInit {
} else {
queryBuilder.where('liveBlog.status = :status', { status });
}
} else if (!isPinned) {
// Default to live blogs if no status specified AND not querying for pinned blogs
// Pinned blogs can be either live or ended
queryBuilder.where('liveBlog.status = :status', {
status: LiveBlogStatus.LIVE,
});
}
// If no status specified, return all live blogs (for admin dashboard)
// Note: Pinned blogs query should still work without status filter
if (category) {
queryBuilder.andWhere('category.slug = :category', { category });
@@ -262,6 +258,18 @@ export class LiveBlogService implements OnModuleInit {
await this.liveBlogRepository.remove(liveBlog);
}
async archive(id: string): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = LiveBlogStatus.ARCHIVED;
return await this.liveBlogRepository.save(liveBlog);
}
async publish(id: string, status: LiveBlogStatus = LiveBlogStatus.DRAFT): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = status;
return await this.liveBlogRepository.save(liveBlog);
}
// Live Blog Update CRUD operations
async createUpdate(
dto: CreateLiveBlogUpdateDto,
@@ -380,7 +388,7 @@ export class LiveBlogService implements OnModuleInit {
const keepAliveInterval = setInterval(() => {
try {
response.write(`: keep-alive\n\n`);
} catch (error) {
} catch {
// Client disconnected, stop sending keep-alive
clearInterval(keepAliveInterval);
}
@@ -443,15 +451,19 @@ export class LiveBlogService implements OnModuleInit {
}
async removeByStrapiId(strapiId: string): Promise<void> {
const liveBlog = await this.liveBlogRepository.findOne({ where: { strapiId } });
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (!liveBlog) {
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
return;
}
await this.liveBlogRepository.remove(liveBlog);
this.logger.log(`Successfully deleted live blog with strapiId: ${strapiId}`);
this.logger.log(
`Successfully deleted live blog with strapiId: ${strapiId}`,
);
}
// Utility methods
+6 -1
View File
@@ -2,7 +2,12 @@ import { Controller, Post, Body, Logger } from '@nestjs/common';
import { StrapiService } from './strapi.service';
interface WebhookBody {
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish';
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish';
model: string;
entry: {
documentId: string;
+91 -44
View File
@@ -5,7 +5,13 @@ 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';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
interface StrapiArticle {
id: number;
@@ -88,18 +94,18 @@ export class StrapiService {
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
@@ -107,25 +113,27 @@ export class StrapiService {
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
private extractLiveBlogImageUrl(strapiLiveBlog: StrapiLiveBlog): string | undefined {
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
@@ -133,7 +141,7 @@ export class StrapiService {
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
@@ -141,7 +149,7 @@ export class StrapiService {
try {
this.logger.log('Starting articles sync from Strapi...');
const response = await lastValueFrom(
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles?populate=*`,
{
@@ -155,7 +163,7 @@ export class StrapiService {
for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
@@ -166,10 +174,12 @@ export class StrapiService {
: ArticleStatus.DRAFT,
tags: [],
featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
imagePosition: (strapiArticle.imagePosition ||
'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition,
videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
};
@@ -191,12 +201,19 @@ export class StrapiService {
async syncSingleArticle(
strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
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}`);
this.logger.log(
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>(
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
{
@@ -206,7 +223,7 @@ export class StrapiService {
);
const strapiArticle = response.data.data;
// Determine status based on publishedAt and event type
let status: ArticleStatus;
if (event === 'entry.unpublish') {
@@ -221,7 +238,7 @@ export class StrapiService {
}
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
@@ -233,7 +250,8 @@ export class StrapiService {
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition,
videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
};
@@ -241,20 +259,26 @@ export class StrapiService {
strapiArticle.documentId,
articleData,
);
this.logger.log(`Successfully synced article: ${strapiArticle.title} with status: ${status}`);
this.logger.log(
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
);
} catch (error: any) {
// 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) {
this.logger.log(`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`);
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`);
this.logger.log(
`Marked article ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) {
this.logger.error(
`Failed to mark article ${strapiId} as draft:`,
@@ -276,7 +300,7 @@ export class StrapiService {
try {
this.logger.log('Starting live blogs sync from Strapi...');
const response = await lastValueFrom(
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs?populate=*`,
{
@@ -290,17 +314,19 @@ export class StrapiService {
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,
imagePosition: (strapiLiveBlog.imagePosition ||
'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition,
videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
};
@@ -322,12 +348,19 @@ export class StrapiService {
async syncSingleLiveBlog(
strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
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}`);
this.logger.log(
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{
@@ -337,18 +370,18 @@ export class StrapiService {
);
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,
@@ -358,7 +391,8 @@ export class StrapiService {
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition,
videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
};
@@ -366,20 +400,26 @@ export class StrapiService {
strapiLiveBlog.documentId,
liveBlogData,
);
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`);
this.logger.log(
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
);
} catch (error: any) {
// 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) {
this.logger.log(`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`);
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`);
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:`,
@@ -413,7 +453,12 @@ export class StrapiService {
}
async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish',
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string },
): Promise<void> {
this.logger.log(
@@ -421,8 +466,10 @@ export class StrapiService {
);
if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}, model: ${data.model}`);
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') {