first commit
هذا الالتزام موجود في:
6
src/modules/follows/dto/toggle-follow.dto.ts
Normal file
6
src/modules/follows/dto/toggle-follow.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsMongoId } from 'class-validator';
|
||||
|
||||
export class ToggleFollowDto {
|
||||
@IsMongoId()
|
||||
targetUserId!: string;
|
||||
}
|
||||
52
src/modules/follows/follows.controller.ts
Normal file
52
src/modules/follows/follows.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { ToggleFollowDto } from './dto/toggle-follow.dto';
|
||||
import { FollowsService } from './follows.service';
|
||||
|
||||
@ApiTags('Follows')
|
||||
@Controller('follows')
|
||||
export class FollowsController {
|
||||
constructor(private readonly followsService: FollowsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('toggle')
|
||||
@Throttle(30, 60_000)
|
||||
async toggleFollow(@CurrentUser() user: JwtPayload, @Body() dto: ToggleFollowDto) {
|
||||
return this.followsService.toggleFollow(user.sub, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('followers/:userId')
|
||||
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getFollowers(userId, query.page, query.limit);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('following/:userId')
|
||||
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getFollowing(userId, query.page, query.limit);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('status/:targetUserId')
|
||||
async status(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.followsService.getFollowStatus(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('suggestions')
|
||||
@Throttle(60, 60_000)
|
||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getSuggestions(user.sub, query.page, query.limit);
|
||||
}
|
||||
}
|
||||
25
src/modules/follows/follows.module.ts
Normal file
25
src/modules/follows/follows.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { OutboxModule } from '../outbox/outbox.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { FollowsController } from './follows.controller';
|
||||
import { FollowsService } from './follows.service';
|
||||
import { FollowsRepository } from './follows.repository';
|
||||
import { Follow, FollowSchema } from './schemas/follow.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OutboxModule,
|
||||
MongooseModule.forFeature([
|
||||
{
|
||||
name: Follow.name,
|
||||
schema: FollowSchema,
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [FollowsController],
|
||||
providers: [FollowsService, FollowsRepository],
|
||||
exports: [FollowsService],
|
||||
})
|
||||
export class FollowsModule {}
|
||||
65
src/modules/follows/follows.repository.ts
Normal file
65
src/modules/follows/follows.repository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Follow, FollowDocument } from './schemas/follow.schema';
|
||||
|
||||
@Injectable()
|
||||
export class FollowsRepository {
|
||||
constructor(@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>) {}
|
||||
|
||||
async findOne(followerId: string, followingId: string): Promise<FollowDocument | null> {
|
||||
return this.followModel
|
||||
.findOne({
|
||||
followerId: new Types.ObjectId(followerId),
|
||||
followingId: new Types.ObjectId(followingId),
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(
|
||||
followerId: string,
|
||||
followingId: string,
|
||||
session?: ClientSession,
|
||||
): Promise<FollowDocument> {
|
||||
const [follow] = await this.followModel.create(
|
||||
[
|
||||
{
|
||||
followerId: new Types.ObjectId(followerId),
|
||||
followingId: new Types.ObjectId(followingId),
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
|
||||
return follow;
|
||||
}
|
||||
|
||||
async deleteById(id: string, session?: ClientSession): Promise<void> {
|
||||
await this.followModel.findByIdAndDelete(id, { session }).exec();
|
||||
}
|
||||
|
||||
async findMany(filter: FilterQuery<FollowDocument>, skip: number, limit: number): Promise<FollowDocument[]> {
|
||||
return this.followModel
|
||||
.find(filter)
|
||||
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<FollowDocument>): Promise<number> {
|
||||
return this.followModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async findFollowingIds(followerId: string): Promise<string[]> {
|
||||
const rows = await this.followModel
|
||||
.find({ followerId: new Types.ObjectId(followerId) })
|
||||
.select({ followingId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.followingId.toString());
|
||||
}
|
||||
}
|
||||
42
src/modules/follows/follows.service.spec.ts
Normal file
42
src/modules/follows/follows.service.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FollowsService } from './follows.service';
|
||||
|
||||
describe('FollowsService', () => {
|
||||
it('keeps follow successful even if notification creation fails and resyncs counters', async () => {
|
||||
const currentUserId = '507f1f77bcf86cd799439011';
|
||||
const targetUserId = '507f191e810c19729de860ea';
|
||||
|
||||
const followsRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'follow-1' }),
|
||||
count: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(6)
|
||||
.mockResolvedValueOnce(14),
|
||||
};
|
||||
const usersRepository = {
|
||||
findById: jest.fn().mockResolvedValue({ id: targetUserId }),
|
||||
setFollowingCount: jest.fn().mockResolvedValue(undefined),
|
||||
setFollowersCount: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const outboxService = {
|
||||
enqueueFollowNotification: jest.fn().mockRejectedValue(new Error('socket down')),
|
||||
};
|
||||
|
||||
const service = new FollowsService(
|
||||
followsRepository as any,
|
||||
usersRepository as any,
|
||||
outboxService as any,
|
||||
);
|
||||
|
||||
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
|
||||
following: true,
|
||||
});
|
||||
expect(usersRepository.setFollowingCount).toHaveBeenCalledWith(currentUserId, 6);
|
||||
expect(usersRepository.setFollowersCount).toHaveBeenCalledWith(targetUserId, 14);
|
||||
expect(outboxService.enqueueFollowNotification).toHaveBeenCalledWith(
|
||||
currentUserId,
|
||||
targetUserId,
|
||||
'follow-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
223
src/modules/follows/follows.service.ts
Normal file
223
src/modules/follows/follows.service.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { OutboxService } from '../outbox/outbox.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { UserDocument } from '../users/schemas/user.schema';
|
||||
import { ToggleFollowDto } from './dto/toggle-follow.dto';
|
||||
import { FollowsRepository } from './follows.repository';
|
||||
|
||||
@Injectable()
|
||||
export class FollowsService {
|
||||
private readonly logger = new Logger(FollowsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly followsRepository: FollowsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly outboxService: OutboxService,
|
||||
) {}
|
||||
|
||||
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
|
||||
const targetUserId = dto.targetUserId;
|
||||
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new BadRequestException('You cannot follow yourself');
|
||||
}
|
||||
|
||||
const targetUser = await this.usersRepository.findById(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('Target user not found');
|
||||
}
|
||||
|
||||
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
|
||||
|
||||
if (existing) {
|
||||
await this.followsRepository.deleteById(existing.id);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
return { following: false };
|
||||
}
|
||||
|
||||
const follow = await this.followsRepository.create(currentUserId, targetUserId);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
|
||||
try {
|
||||
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Follow notification failed for actor=${currentUserId} recipient=${targetUserId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { following: true };
|
||||
}
|
||||
|
||||
async getFollowers(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findMany({ followingId: userId }, skip, limit),
|
||||
this.followsRepository.count({ followingId: userId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getFollowing(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findMany({ followerId: userId }, skip, limit),
|
||||
this.followsRepository.count({ followerId: userId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getFollowStatus(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
return { following: false, targetUserId };
|
||||
}
|
||||
|
||||
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
|
||||
return {
|
||||
following: !!existing,
|
||||
targetUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async getSuggestions(currentUserId: string, page = 1, limit = 20) {
|
||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||
if (!currentUser) {
|
||||
throw new NotFoundException('Current user not found');
|
||||
}
|
||||
|
||||
const followingIds = await this.followsRepository.findFollowingIds(currentUserId);
|
||||
const excludedIds = new Set<string>([currentUserId, ...followingIds]);
|
||||
|
||||
const candidates = await this.usersRepository.findSuggestionCandidates(
|
||||
{
|
||||
_id: { $nin: Array.from(excludedIds).map((id) => new Types.ObjectId(id)) },
|
||||
isDisabled: false,
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
const ranked = candidates
|
||||
.map((candidate) => ({
|
||||
user: candidate,
|
||||
score: this.calculateSuggestionScore(currentUser, candidate),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
|
||||
|
||||
const total = ranked.length;
|
||||
const skip = (page - 1) * limit;
|
||||
const items = ranked.slice(skip, skip + limit).map((entry) => ({
|
||||
user: entry.user,
|
||||
score: entry.score,
|
||||
reasons: this.buildSuggestionReasons(currentUser, entry.user),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {
|
||||
const sharedRoles = this.intersectionCount(currentUser.musicRoles, candidate.musicRoles);
|
||||
const sharedGenres = this.intersectionCount(currentUser.musicGenres, candidate.musicGenres);
|
||||
const sharedInstruments = this.intersectionCount(
|
||||
currentUser.favoriteInstruments,
|
||||
candidate.favoriteInstruments,
|
||||
);
|
||||
const sharedMaqamat = this.intersectionCount(currentUser.favoriteMaqamat, candidate.favoriteMaqamat);
|
||||
const sameLocation = this.normalize(currentUser.location) === this.normalize(candidate.location);
|
||||
|
||||
let score = 0;
|
||||
score += sharedRoles * 15;
|
||||
score += sharedGenres * 8;
|
||||
score += sharedInstruments * 6;
|
||||
score += sharedMaqamat * 6;
|
||||
score += sameLocation ? 20 : 0;
|
||||
score += candidate.isVerified ? 25 : 0;
|
||||
score += Math.min(25, Math.floor(candidate.followersCount / 100));
|
||||
score += Math.random();
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private buildSuggestionReasons(currentUser: UserDocument, candidate: UserDocument): string[] {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (this.intersectionCount(currentUser.musicRoles, candidate.musicRoles) > 0) {
|
||||
reasons.push('shared_music_roles');
|
||||
}
|
||||
if (this.intersectionCount(currentUser.musicGenres, candidate.musicGenres) > 0) {
|
||||
reasons.push('shared_genres');
|
||||
}
|
||||
if (this.normalize(currentUser.location) === this.normalize(candidate.location)) {
|
||||
reasons.push('same_location');
|
||||
}
|
||||
if (candidate.isVerified) {
|
||||
reasons.push('verified_account');
|
||||
}
|
||||
if (candidate.followersCount > 500) {
|
||||
reasons.push('popular_creator');
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private normalize(value?: string): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
private intersectionCount(a: string[] = [], b: string[] = []): number {
|
||||
if (!a.length || !b.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const right = new Set(b.map((item) => item.toLowerCase()));
|
||||
let count = 0;
|
||||
for (const item of a) {
|
||||
if (right.has(item.toLowerCase())) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncFollowCounts(currentUserId: string, targetUserId: string): Promise<void> {
|
||||
const [followingCount, followersCount] = await Promise.all([
|
||||
this.followsRepository.count({ followerId: currentUserId }),
|
||||
this.followsRepository.count({ followingId: targetUserId }),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
this.usersRepository.setFollowingCount(currentUserId, followingCount),
|
||||
this.usersRepository.setFollowersCount(targetUserId, followersCount),
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
src/modules/follows/schemas/follow.schema.ts
Normal file
17
src/modules/follows/schemas/follow.schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type FollowDocument = HydratedDocument<Follow>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Follow {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
followerId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
followingId!: Types.ObjectId;
|
||||
}
|
||||
|
||||
export const FollowSchema = SchemaFactory.createForClass(Follow);
|
||||
FollowSchema.index({ followerId: 1, followingId: 1 }, { unique: true });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم