308 lines
8.6 KiB
TypeScript
308 lines
8.6 KiB
TypeScript
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<Article>,
|
|
@Inject(forwardRef(() => StrapiService))
|
|
private readonly strapiService: StrapiService,
|
|
) {}
|
|
|
|
async create(dto: CreateArticleDto): Promise<Article> {
|
|
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<Article> {
|
|
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<Article> {
|
|
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<Article> {
|
|
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<void> {
|
|
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<Article> {
|
|
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<Article> {
|
|
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<CreateArticleDto>,
|
|
): Promise<Article> {
|
|
// 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<void> {
|
|
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<Article | null> {
|
|
return this.articleRepository.findOne({
|
|
where: {
|
|
isHero: true,
|
|
status: ArticleStatus.PUBLISHED,
|
|
},
|
|
relations: ['author', 'category'],
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
}
|