live blog implemented

This commit is contained in:
dimitar committed 2026-01-29 04:01:55 +01:00
1 parent e8893c1aae
commit 0351726eeb
36 files changed
+7759 -253

No files matched your search

+20
View File
@@ -13,6 +13,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
@@ -2410,6 +2411,19 @@
}
}
},
"node_modules/@nestjs/event-emitter": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz",
"integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.10",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.10.tgz",
@@ -5741,6 +5755,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+1
View File
@@ -26,6 +26,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
+10 -2
View File
@@ -5,7 +5,14 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.module';
import { Article, Author, Category } from './modules/entities';
import { LiveBlogModule } from './modules/live-blog.module';
import {
Article,
Author,
Category,
LiveBlog,
LiveBlogUpdate,
} from './modules/entities';
@Module({
imports: [
@@ -15,12 +22,13 @@ import { Article, Author, Category } from './modules/entities';
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.DATABASE_PATH ?? './database.sqlite',
entities: [Article, Author, Category],
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
}),
ArticlesModule,
StrapiModule,
LiveBlogModule,
],
controllers: [AppController],
providers: [AppService],
+129 -1
View File
@@ -5,8 +5,10 @@ import {
IsArray,
IsUUID,
IsNumber,
IsBoolean,
IsDate,
} from 'class-validator';
import { ArticleStatus } from './entities';
import { ArticleStatus, LiveBlogStatus } from './entities';
export class CreateArticleDto {
@IsString()
@@ -120,3 +122,129 @@ export class FindArticlesDto {
@IsOptional()
limit?: number;
}
export class CreateLiveBlogDto {
@IsString()
title: string;
@IsString()
slug: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
}
export class UpdateLiveBlogDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
}
export class CreateLiveBlogUpdateDto {
@IsString()
content: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class UpdateLiveBlogUpdateDto {
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
+96 -1
View File
@@ -7,6 +7,7 @@ import {
ManyToOne,
JoinColumn,
ValueTransformer,
OneToMany,
} from 'typeorm';
class ArrayTransformer implements ValueTransformer {
@@ -15,7 +16,7 @@ class ArrayTransformer implements ValueTransformer {
}
from(value: string): string[] {
try {
return value ? JSON.parse(value) : [];
return value ? (JSON.parse(value) as string[]) : [];
} catch {
return [];
}
@@ -28,6 +29,13 @@ export enum ArticleStatus {
ARCHIVED = 'archived',
}
export enum LiveBlogStatus {
DRAFT = 'draft',
LIVE = 'live',
ENDED = 'ended',
ARCHIVED = 'archived',
}
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn('uuid')
@@ -145,3 +153,90 @@ export class Article {
@JoinColumn({ name: 'categoryId' })
category: Category;
}
@Entity('live_blogs')
export class LiveBlog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ unique: true })
slug: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'text',
default: 'draft',
})
status: LiveBlogStatus;
@Column({ nullable: true })
strapiId: string;
@Column({ nullable: true })
authorId: string;
@Column({ nullable: true })
categoryId: string;
@Column({ default: 0 })
viewCount: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'authorId' })
author: Author;
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' })
category: Category;
@OneToMany(() => LiveBlogUpdate, (update) => update.liveBlog, {
cascade: true,
})
updates: LiveBlogUpdate[];
}
@Entity('live_blog_updates')
export class LiveBlogUpdate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('text')
content: string;
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true })
authorId: string;
@Column({ type: 'datetime', nullable: true })
scheduledAt: Date;
@Column({ nullable: true })
strapiId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'authorId' })
author: Author;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
}
+128
View File
@@ -0,0 +1,128 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
ValidationPipe,
Res,
Logger,
Headers,
} from '@nestjs/common';
import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service';
import {
CreateLiveBlogDto,
UpdateLiveBlogDto,
FindLiveBlogsDto,
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
@Controller('live-blogs')
export class LiveBlogController {
private readonly logger = new Logger(LiveBlogController.name);
constructor(private readonly liveBlogService: LiveBlogService) {}
// Live Blog CRUD operations
@Post()
create(
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
) {
return this.liveBlogService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
) {
return this.liveBlogService.findAll(dto);
}
@Get('recent')
getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id);
}
@Get('slug/:slug')
findBySlug(@Param('slug') slug: string) {
return this.liveBlogService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
) {
return this.liveBlogService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.liveBlogService.remove(id);
}
// Live Blog Updates CRUD operations
@Post(':id/updates')
createUpdate(
@Param('id') liveBlogId: string,
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
) {
return this.liveBlogService.createUpdate(dto, liveBlogId);
}
@Get(':id/updates')
findUpdates(
@Param('id') liveBlogId: string,
@Query('page') page = 1,
@Query('limit') limit = 50,
) {
return this.liveBlogService.findUpdates(
liveBlogId,
parseInt(page.toString()),
parseInt(limit.toString()),
);
}
@Put(':id/updates/:updateId')
updateUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
) {
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
}
@Delete(':id/updates/:updateId')
removeUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
) {
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
}
// SSE endpoint for real-time updates
@Get(':id/stream')
stream(
@Param('id') liveBlogId: string,
@Res() response: ExpressResponse,
@Headers('last-event-id') lastEventId?: string,
) {
this.logger.log(`SSE connection request for live blog ${liveBlogId}`);
if (lastEventId) {
this.logger.log(`Client resuming with last event ID: ${lastEventId}`);
}
this.liveBlogService.createStream(liveBlogId, response);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } 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';
@Module({
imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(),
],
controllers: [LiveBlogController],
providers: [LiveBlogService],
exports: [LiveBlogService],
})
export class LiveBlogModule {}
+372
View File
@@ -0,0 +1,372 @@
import {
Injectable,
NotFoundException,
Logger,
OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Response } from 'express';
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
import {
CreateLiveBlogDto,
UpdateLiveBlogDto,
FindLiveBlogsDto,
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
interface SseClient {
id: string;
response: Response;
blogId: string;
}
interface LiveBlogUpdateEvent {
blogId: string;
update: LiveBlogUpdate;
}
interface LiveBlogStatusChangeEvent {
blogId: string;
status: LiveBlogStatus;
}
interface LiveBlogPinUpdateEvent {
blogId: string;
updateId: string;
isPinned: boolean;
}
@Injectable()
export class LiveBlogService implements OnModuleInit {
private readonly logger = new Logger(LiveBlogService.name);
private readonly sseClients = new Map<string, SseClient>();
constructor(
@InjectRepository(LiveBlog)
private readonly liveBlogRepository: Repository<LiveBlog>,
@InjectRepository(LiveBlogUpdate)
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
private readonly eventEmitter: EventEmitter2,
) {}
onModuleInit() {
this.eventEmitter.on('live-blog.update', (data: LiveBlogUpdateEvent) => {
this.broadcastToClients(data.blogId, {
type: 'update',
data: data.update,
});
});
this.eventEmitter.on(
'live-blog.status-change',
(data: LiveBlogStatusChangeEvent) => {
this.broadcastToClients(data.blogId, {
type: 'status-change',
data: { status: data.status },
});
},
);
this.eventEmitter.on(
'live-blog.pin-update',
(data: LiveBlogPinUpdateEvent) => {
this.broadcastToClients(data.blogId, {
type: 'pin-update',
data: { updateId: data.updateId, isPinned: data.isPinned },
});
},
);
}
// Live Blog CRUD operations
async create(dto: CreateLiveBlogDto): Promise<LiveBlog> {
const liveBlog = this.liveBlogRepository.create({
...dto,
status: dto.status || LiveBlogStatus.DRAFT,
});
return await this.liveBlogRepository.save(liveBlog);
}
async findAll(
dto: FindLiveBlogsDto,
): Promise<{ data: LiveBlog[]; total: number }> {
const {
category,
author,
status = LiveBlogStatus.LIVE,
search,
page = 1,
limit = 10,
} = dto;
const queryBuilder = this.liveBlogRepository
.createQueryBuilder('liveBlog')
.leftJoinAndSelect('liveBlog.author', 'author')
.leftJoinAndSelect('liveBlog.category', 'category')
.where('liveBlog.status = :status', { status });
if (category) {
queryBuilder.andWhere('category.slug = :category', { category });
}
if (author) {
queryBuilder.andWhere('author.slug = :author', { author });
}
if (search) {
queryBuilder.andWhere(
'(liveBlog.title ILIKE :search OR liveBlog.description ILIKE :search)',
{ search: `%${search}%` },
);
}
const [data, total] = await queryBuilder
.orderBy('liveBlog.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
async findOne(id: string): Promise<LiveBlog> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { id },
relations: ['author', 'category', 'updates'],
});
if (!liveBlog) {
throw new NotFoundException(`Live blog with ID ${id} not found`);
}
// Increment view count
await this.liveBlogRepository.increment({ id }, 'viewCount', 1);
return liveBlog;
}
async findBySlug(slug: string): Promise<LiveBlog> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { slug },
relations: ['author', 'category', 'updates'],
});
if (!liveBlog) {
throw new NotFoundException(`Live blog with slug ${slug} not found`);
}
// Increment view count
await this.liveBlogRepository.increment({ slug }, 'viewCount', 1);
return liveBlog;
}
async update(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
Object.assign(liveBlog, dto);
const updatedBlog = await this.liveBlogRepository.save(liveBlog);
// Emit status change event
if (dto.status && dto.status !== liveBlog.status) {
this.eventEmitter.emit('live-blog.status-change', {
blogId: id,
status: dto.status,
});
}
return updatedBlog;
}
async remove(id: string): Promise<void> {
const liveBlog = await this.findOne(id);
await this.liveBlogRepository.remove(liveBlog);
}
// Live Blog Update CRUD operations
async createUpdate(
dto: CreateLiveBlogUpdateDto,
liveBlogId: string,
): Promise<LiveBlogUpdate> {
const liveBlogEntity = await this.findOne(liveBlogId);
const update = this.liveBlogUpdateRepository.create({
...dto,
liveBlog: liveBlogEntity,
});
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
// Emit update event
this.eventEmitter.emit('live-blog.update', {
blogId: liveBlogId,
update: savedUpdate,
});
return savedUpdate;
}
async findUpdates(
liveBlogId: string,
page = 1,
limit = 50,
): Promise<{ data: LiveBlogUpdate[]; total: number }> {
const [data, total] = await this.liveBlogUpdateRepository.findAndCount({
where: { liveBlog: { id: liveBlogId } },
relations: ['author'],
order: {
isPinned: 'DESC',
createdAt: 'ASC',
},
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
async updateUpdate(
liveBlogId: string,
updateId: string,
dto: UpdateLiveBlogUpdateDto,
): Promise<LiveBlogUpdate> {
const update = await this.liveBlogUpdateRepository.findOne({
where: { id: updateId, liveBlog: { id: liveBlogId } },
relations: ['liveBlog'],
});
if (!update) {
throw new NotFoundException(`Update with ID ${updateId} not found`);
}
Object.assign(update, dto);
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
// Emit pin change event
if (dto.isPinned !== undefined) {
this.eventEmitter.emit('live-blog.pin-update', {
blogId: liveBlogId,
updateId,
isPinned: dto.isPinned,
});
}
return savedUpdate;
}
async removeUpdate(liveBlogId: string, updateId: string): Promise<void> {
const update = await this.liveBlogUpdateRepository.findOne({
where: { id: updateId, liveBlog: { id: liveBlogId } },
});
if (!update) {
throw new NotFoundException(`Update with ID ${updateId} not found`);
}
await this.liveBlogUpdateRepository.remove(update);
}
// SSE operations
createStream(liveBlogId: string, response: Response): void {
// Verify live blog exists and is live
this.findOne(liveBlogId).catch(() => {
response.end();
return;
});
const clientId = `${Date.now()}-${Math.random()}`;
// Set SSE headers
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
});
// Send initial connection message
response.write(
`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`,
);
// Store client connection
this.sseClients.set(clientId, {
id: clientId,
response,
blogId: liveBlogId,
});
// Handle client disconnect
response.on('close', () => {
this.sseClients.delete(clientId);
this.logger.log(
`Client ${clientId} disconnected from live blog ${liveBlogId}`,
);
});
this.logger.log(`Client ${clientId} connected to live blog ${liveBlogId}`);
}
private broadcastToClients(liveBlogId: string, message: any): void {
const clients = Array.from(this.sseClients.values()).filter(
(client) => client.blogId === liveBlogId,
);
clients.forEach((client) => {
try {
client.response.write(`data: ${JSON.stringify(message)}\n\n`);
} catch (error) {
this.logger.error(
`Failed to send message to client ${client.id}:`,
error,
);
this.sseClients.delete(client.id);
}
});
this.logger.debug(
`Broadcasted message to ${clients.length} clients for live blog ${liveBlogId}`,
);
}
// Strapi sync operations
async syncFromStrapi(
strapiId: string,
data: Partial<CreateLiveBlogDto>,
): Promise<LiveBlog> {
let liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (!liveBlog) {
liveBlog = this.liveBlogRepository.create({
strapiId,
...data,
status: data.status || LiveBlogStatus.DRAFT,
});
} else {
Object.assign(liveBlog, data);
}
return await this.liveBlogRepository.save(liveBlog);
}
// Utility methods
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
const since = new Date();
since.setHours(since.getHours() - hours);
return await this.liveBlogRepository
.createQueryBuilder('liveBlog')
.leftJoinAndSelect('liveBlog.author', 'author')
.leftJoinAndSelect('liveBlog.updates', 'updates')
.where('liveBlog.status = :status', { status: LiveBlogStatus.LIVE })
.andWhere('updates.createdAt > :since', { since })
.orderBy('updates.createdAt', 'DESC')
.getMany();
}
}
+28 -1
View File
@@ -28,6 +28,33 @@ export class StrapiController {
@Post('sync/all')
async syncAllArticles() {
await this.strapiService.syncArticles();
return { message: 'Sync completed' };
return { message: 'Articles sync completed' };
}
@Post('live-blog')
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
const { event, model, entry } = body;
if (model !== 'live-blog') {
return { message: 'Ignored: not a live blog' };
}
await this.strapiService.handleWebhook(event, entry);
return { message: 'Live blog webhook processed successfully' };
}
@Post('sync/live-blogs')
async syncAllLiveBlogs() {
await this.strapiService.syncLiveBlogs();
return { message: 'Live blogs sync completed' };
}
@Post('sync/everything')
async syncEverything() {
await Promise.all([
this.strapiService.syncArticles(),
this.strapiService.syncLiveBlogs(),
]);
return { message: 'Full sync completed' };
}
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios';
import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module';
@Module({
imports: [HttpModule, ArticlesModule],
imports: [HttpModule, ArticlesModule, LiveBlogModule],
controllers: [StrapiController],
providers: [StrapiService],
exports: [StrapiService],
+118 -5
View File
@@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './articles.dto';
import { ArticleStatus } from './entities';
import { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
import { ArticleStatus, LiveBlogStatus } from './entities';
interface StrapiArticle {
id: number;
@@ -18,6 +19,18 @@ interface StrapiArticle {
updatedAt: 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;
}
interface StrapiResponse<T> {
data: T;
meta: {
@@ -40,6 +53,7 @@ export class StrapiService {
private readonly configService: ConfigService,
private readonly httpService: HttpService,
private readonly articlesService: ArticlesService,
private readonly liveBlogService: LiveBlogService,
) {
this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -140,17 +154,116 @@ export class StrapiService {
}
}
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`,
{
headers: this.getHeaders(),
},
),
);
const strapiLiveBlogs = response.data.data;
let syncedCount = 0;
for (const strapiLiveBlog of strapiLiveBlogs) {
const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title,
description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
};
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): Promise<void> {
try {
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}`);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{
headers: this.getHeaders(),
},
),
);
const strapiLiveBlog = response.data.data;
const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title,
description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
};
await this.liveBlogService.syncFromStrapi(
strapiLiveBlog.documentId,
liveBlogData,
);
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title}`);
} catch (error) {
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',
data: { documentId: string },
data: { documentId: string; model?: string },
): Promise<void> {
this.logger.log(`Received webhook event: ${event}`);
this.logger.log(
`Received webhook event: ${event} for model: ${data.model}`,
);
if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}`);
return;
}
await this.syncSingleArticle(data.documentId);
// Route to appropriate sync method based on model
if (data.model === 'article') {
await this.syncSingleArticle(data.documentId);
} else if (data.model === 'live-blog') {
await this.syncSingleLiveBlog(data.documentId);
} else {
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
}
}
}