هذا الالتزام موجود في:
2026-04-20 15:12:16 +03:00
التزام 28f7241bcd
172 ملفات معدلة مع 21907 إضافات و0 حذوفات

عرض الملف

@@ -0,0 +1,6 @@
import { IsMongoId } from 'class-validator';
export class ToggleFollowDto {
@IsMongoId()
targetUserId!: string;
}

عرض الملف

@@ -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);
}
}

عرض الملف

@@ -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 {}

عرض الملف

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

عرض الملف

@@ -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',
);
});
});

عرض الملف

@@ -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),
]);
}
}

عرض الملف

@@ -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 });