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

+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();
}
}