import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Types } from 'mongoose'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { NotificationsService } from '../notifications/notifications.service'; import { CommentsRepository } from '../comments/comments.repository'; import { PostsRepository } from '../posts/posts.repository'; import { LikesRepository } from './likes.repository'; import { ToggleLikeDto } from './dto/toggle-like.dto'; @Injectable() export class LikesService { private readonly logger = new Logger(LikesService.name); constructor( private readonly likesRepository: LikesRepository, private readonly postsRepository: PostsRepository, private readonly commentsRepository: CommentsRepository, private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, ) {} async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); return existing ? this.unlike(userId, dto) : this.like(userId, dto); } async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { await this.assertTargetExists(dto); const notificationContext = await this.resolveNotificationContext(dto); const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); if (existing) { return { liked: true, targetId: dto.targetId, targetType: dto.targetType }; } await this.likesRepository.create(userId, dto.targetId, dto.targetType); if (dto.targetType === 'post') { await this.postsRepository.incrementLikesCount(dto.targetId, 1); } await this.feedVersionService.bumpGlobalVersion(); if (notificationContext.recipientId && notificationContext.recipientId !== userId) { try { await this.notificationsService.createLikeNotification( userId, notificationContext.recipientId, dto.targetId, { resourceType: dto.targetType, previewText: notificationContext.previewText, }, ); } catch (error) { this.logger.warn( `Like notification failed for actor=${userId} recipient=${notificationContext.recipientId}: ${ error instanceof Error ? error.message : 'unknown error' }`, ); } } return { liked: true, targetId: dto.targetId, targetType: dto.targetType }; } async unlike(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { await this.assertTargetExists(dto); const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); if (!existing) { return { liked: false, targetId: dto.targetId, targetType: dto.targetType }; } await this.likesRepository.deleteById(existing.id); if (dto.targetType === 'post') { await this.postsRepository.incrementLikesCount(dto.targetId, -1); } await this.feedVersionService.bumpGlobalVersion(); return { liked: false, targetId: dto.targetId, targetType: dto.targetType }; } async getStatus(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> { const targetExists = await this.targetExists(dto); if (!targetExists) { return { liked: false, targetId: dto.targetId, targetType: dto.targetType }; } const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); return { liked: !!existing, targetId: dto.targetId, targetType: dto.targetType }; } private async assertTargetExists(dto: ToggleLikeDto): Promise { const targetExists = await this.targetExists(dto); if (!targetExists) { throw new NotFoundException(dto.targetType === 'post' ? 'Post not found' : 'Comment not found'); } } private async targetExists(dto: ToggleLikeDto): Promise { if (dto.targetType === 'post') { const post = await this.postsRepository.findById(dto.targetId); return !!post; } const comment = await this.commentsRepository.findById(dto.targetId); return !!comment; } private async resolveNotificationContext( dto: ToggleLikeDto, ): Promise<{ recipientId: string; previewText: string }> { if (dto.targetType === 'post') { const post = await this.postsRepository.findById(dto.targetId); return { recipientId: this.extractEntityId(post?.authorId), previewText: (post?.content ?? '').slice(0, 140), }; } const comment = await this.commentsRepository.findById(dto.targetId); return { recipientId: comment?.authorId?.toString?.() ?? '', previewText: (comment?.content ?? '').slice(0, 140), }; } private extractEntityId(value: unknown): string { if (!value) { return ''; } if (typeof value === 'string') { return value; } if (value instanceof Types.ObjectId) { return value.toString(); } if (typeof value === 'object') { const candidate = value as { _id?: unknown; id?: unknown }; if (candidate._id instanceof Types.ObjectId) { return candidate._id.toString(); } if (typeof candidate._id === 'string') { return candidate._id; } if (typeof candidate.id === 'string') { return candidate.id; } } return ''; } }