first commit
هذا الالتزام موجود في:
12
src/modules/likes/dto/toggle-like.dto.ts
Normal file
12
src/modules/likes/dto/toggle-like.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsMongoId } from 'class-validator';
|
||||
|
||||
export class ToggleLikeDto {
|
||||
@ApiProperty()
|
||||
@IsMongoId()
|
||||
targetId!: string;
|
||||
|
||||
@ApiProperty({ enum: ['post', 'comment'] })
|
||||
@IsIn(['post', 'comment'])
|
||||
targetType!: 'post' | 'comment';
|
||||
}
|
||||
43
src/modules/likes/likes.controller.ts
Normal file
43
src/modules/likes/likes.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { ToggleLikeDto } from './dto/toggle-like.dto';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@ApiTags('Likes')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('likes')
|
||||
export class LikesController {
|
||||
constructor(private readonly likesService: LikesService) {}
|
||||
|
||||
@Post()
|
||||
async like(@CurrentUser() user: JwtPayload, @Body() dto: ToggleLikeDto) {
|
||||
return this.likesService.like(user.sub, dto);
|
||||
}
|
||||
|
||||
@Delete(':targetType/:targetId')
|
||||
async unlike(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('targetId') targetId: string,
|
||||
@Param('targetType') targetType: 'post' | 'comment',
|
||||
) {
|
||||
return this.likesService.unlike(user.sub, { targetId, targetType });
|
||||
}
|
||||
|
||||
@Get('status/:targetType/:targetId')
|
||||
async getStatus(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('targetId') targetId: string,
|
||||
@Param('targetType') targetType: 'post' | 'comment',
|
||||
) {
|
||||
return this.likesService.getStatus(user.sub, { targetId, targetType });
|
||||
}
|
||||
|
||||
@Post('toggle')
|
||||
async toggle(@CurrentUser() user: JwtPayload, @Body() dto: ToggleLikeDto) {
|
||||
return this.likesService.toggle(user.sub, dto);
|
||||
}
|
||||
}
|
||||
20
src/modules/likes/likes.module.ts
Normal file
20
src/modules/likes/likes.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { CommentsModule } from '../comments/comments.module';
|
||||
import { PostsModule } from '../posts/posts.module';
|
||||
import { Like, LikeSchema } from './schemas/like.schema';
|
||||
import { LikesController } from './likes.controller';
|
||||
import { LikesRepository } from './likes.repository';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
|
||||
PostsModule,
|
||||
CommentsModule,
|
||||
],
|
||||
controllers: [LikesController],
|
||||
providers: [LikesService, LikesRepository],
|
||||
exports: [LikesService],
|
||||
})
|
||||
export class LikesModule {}
|
||||
31
src/modules/likes/likes.repository.ts
Normal file
31
src/modules/likes/likes.repository.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { Like, LikeDocument } from './schemas/like.schema';
|
||||
|
||||
@Injectable()
|
||||
export class LikesRepository {
|
||||
constructor(@InjectModel(Like.name) private readonly likeModel: Model<LikeDocument>) {}
|
||||
|
||||
async findOne(userId: string, targetId: string, targetType: 'post' | 'comment'): Promise<LikeDocument | null> {
|
||||
return this.likeModel
|
||||
.findOne({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
targetType,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(userId: string, targetId: string, targetType: 'post' | 'comment'): Promise<LikeDocument> {
|
||||
return this.likeModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
targetType,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.likeModel.findByIdAndDelete(id).exec();
|
||||
}
|
||||
}
|
||||
30
src/modules/likes/likes.service.spec.ts
Normal file
30
src/modules/likes/likes.service.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
describe('LikesService', () => {
|
||||
it('returns liked false from status when target post no longer exists', async () => {
|
||||
const likesRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const postsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const commentsRepository = {
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new LikesService(
|
||||
likesRepository as any,
|
||||
postsRepository as any,
|
||||
commentsRepository as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getStatus('user-1', { targetId: '507f1f77bcf86cd799439011', targetType: 'post' }),
|
||||
).resolves.toEqual({
|
||||
liked: false,
|
||||
targetId: '507f1f77bcf86cd799439011',
|
||||
targetType: 'post',
|
||||
});
|
||||
expect(likesRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
78
src/modules/likes/likes.service.ts
Normal file
78
src/modules/likes/likes.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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 {
|
||||
constructor(
|
||||
private readonly likesRepository: LikesRepository,
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly commentsRepository: CommentsRepository,
|
||||
) {}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
if (dto.targetType === 'post') {
|
||||
const post = await this.postsRepository.findById(dto.targetId);
|
||||
return !!post;
|
||||
}
|
||||
|
||||
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||
return !!comment;
|
||||
}
|
||||
}
|
||||
19
src/modules/likes/schemas/like.schema.ts
Normal file
19
src/modules/likes/schemas/like.schema.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
|
||||
export type LikeDocument = HydratedDocument<Like>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Like {
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
targetId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, enum: ['post', 'comment'] })
|
||||
targetType!: 'post' | 'comment';
|
||||
}
|
||||
|
||||
export const LikeSchema = SchemaFactory.createForClass(Like);
|
||||
LikeSchema.index({ userId: 1, targetId: 1, targetType: 1 }, { unique: true });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم