feat: stabilize social backend contracts

هذا الالتزام موجود في:
boutmoun123
2026-05-31 16:13:23 +03:00
الأصل ad6da6754d
التزام 49e132909e
40 ملفات معدلة مع 12037 إضافات و9562 حذوفات

عرض الملف

@@ -0,0 +1,58 @@
import { Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.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 { FollowsService } from './follows.service';
@ApiTags('Users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users')
export class FollowsUsersController {
constructor(private readonly followsService: FollowsService) {}
@Get('me/followers')
async myFollowers(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowers(user.sub, query, user.sub);
}
@Get('me/following')
async myFollowing(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowing(user.sub, query, user.sub);
}
@Post(':userId/follow')
async followUser(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.followUser(user.sub, targetUserId);
}
@Delete(':userId/follow')
async unfollowUser(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.unfollowUser(user.sub, targetUserId);
}
@Get(':userId/follow-status')
async followStatus(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.getFollowStatus(user.sub, targetUserId);
}
@Get(':userId/followers')
async followers(
@CurrentUser() user: JwtPayload,
@Param('userId') targetUserId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowers(targetUserId, query, user.sub);
}
@Get(':userId/following')
async following(
@CurrentUser() user: JwtPayload,
@Param('userId') targetUserId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowing(targetUserId, query, user.sub);
}
}

عرض الملف

@@ -24,15 +24,23 @@ export class FollowsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('followers/:userId')
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowers(userId, query);
async followers(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowers(userId, query, user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('following/:userId')
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowing(userId, query);
async following(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowing(userId, query, user.sub);
}
@ApiBearerAuth()

عرض الملف

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { OutboxModule } from '../outbox/outbox.module';
import { UsersModule } from '../users/users.module';
import { FollowsController } from './follows.controller';
import { FollowsUsersController } from './follows-users.controller';
import { FollowsService } from './follows.service';
import { FollowsRepository } from './follows.repository';
import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema';
@@ -10,6 +12,7 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
@Module({
imports: [
BlocksModule,
UsersModule,
OutboxModule,
MongooseModule.forFeature([
@@ -23,7 +26,7 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
},
]),
],
controllers: [FollowsController],
controllers: [FollowsController, FollowsUsersController],
providers: [FollowsService, FollowsRepository],
exports: [FollowsService, FollowsRepository],
})

عرض الملف

@@ -39,6 +39,15 @@ export class FollowsRepository {
return follow;
}
isDuplicateKeyError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: number }).code === 11000
);
}
async deleteById(id: string, session?: ClientSession): Promise<void> {
await this.followModel.findByIdAndDelete(id, { session }).exec();
}
@@ -83,6 +92,28 @@ export class FollowsRepository {
.exec();
}
async findFollowerUserIds(followingId: string, skip: number, limit: number, sort: Record<string, 1 | -1>) {
return this.followModel
.find({ followingId: new Types.ObjectId(followingId) })
.select({ followerId: 1 })
.sort(sort)
.skip(skip)
.limit(limit)
.lean()
.exec();
}
async findFollowingUserIds(followerId: string, skip: number, limit: number, sort: Record<string, 1 | -1>) {
return this.followModel
.find({ followerId: new Types.ObjectId(followerId) })
.select({ followingId: 1 })
.sort(sort)
.skip(skip)
.limit(limit)
.lean()
.exec();
}
async upsertPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument> {
return this.followRequestModel
.findOneAndUpdate(

عرض الملف

@@ -21,12 +21,16 @@ describe('FollowsService', () => {
const outboxService = {
enqueueFollowNotification: jest.fn().mockRejectedValue(new Error('socket down')),
};
const blocksRepository = {
findAnyBetween: jest.fn().mockResolvedValue(null),
};
const service = new FollowsService(
followsRepository as any,
usersRepository as any,
outboxService as any,
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
blocksRepository as any,
);
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
@@ -40,4 +44,51 @@ describe('FollowsService', () => {
'follow-1',
);
});
it('prevents users from following themselves', async () => {
const userId = '507f1f77bcf86cd799439011';
const service = new FollowsService(
{} as any,
{} as any,
{} as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ findAnyBetween: jest.fn() } as any,
);
await expect(service.followUser(userId, userId)).rejects.toThrow('You cannot follow yourself');
});
it('returns already-following response without creating duplicate notification', async () => {
const currentUserId = '507f1f77bcf86cd799439011';
const targetUserId = '507f191e810c19729de860ea';
const followsRepository = {
findOne: jest.fn().mockResolvedValue({ id: 'existing-follow' }),
count: jest.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2),
create: jest.fn(),
};
const usersRepository = {
findById: jest.fn().mockResolvedValue({ id: targetUserId, isDisabled: false, isPrivate: false }),
setFollowingCount: jest.fn(),
setFollowersCount: jest.fn(),
};
const outboxService = {
enqueueFollowNotification: jest.fn(),
};
const service = new FollowsService(
followsRepository as any,
usersRepository as any,
outboxService as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ findAnyBetween: jest.fn().mockResolvedValue(null) } as any,
);
await expect(service.followUser(currentUserId, targetUserId)).resolves.toMatchObject({
message: 'Already following user',
isFollowing: true,
followersCount: 2,
followingCount: 1,
});
expect(followsRepository.create).not.toHaveBeenCalled();
expect(outboxService.enqueueFollowNotification).not.toHaveBeenCalled();
});
});

عرض الملف

@@ -4,6 +4,7 @@ import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { BlocksRepository } from '../blocks/blocks.repository';
import { OutboxService } from '../outbox/outbox.service';
import { UsersRepository } from '../users/users.repository';
import { UserDocument } from '../users/schemas/user.schema';
@@ -19,23 +20,19 @@ export class FollowsService {
private readonly usersRepository: UsersRepository,
private readonly outboxService: OutboxService,
private readonly feedVersionService: FeedVersionService,
private readonly blocksRepository: BlocksRepository,
) {}
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 targetUser = await this.findActiveTargetUser(targetUserId);
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
@@ -46,37 +43,107 @@ export class FollowsService {
return { following: false };
}
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
if (block) {
throw new BadRequestException('You cannot follow this user');
}
if (targetUser.isPrivate) {
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
return { following: false, requested: true, requestId: request.id };
}
const follow = await this.followsRepository.create(currentUserId, targetUserId);
let followId: string | null = null;
try {
const follow = await this.followsRepository.create(currentUserId, targetUserId);
followId = follow.id;
} catch (error) {
if (!this.followsRepository.isDuplicateKeyError(error)) {
throw error;
}
}
await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
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'
}`,
);
if (followId) {
await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
}
return { following: true };
}
async getFollowers(userId: string, query: PaginationQueryDto) {
async followUser(currentUserId: string, targetUserId: string) {
await this.assertCanFollowTarget(currentUserId, targetUserId);
const targetUser = await this.findActiveTargetUser(targetUserId);
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
if (existing) {
const counts = await this.syncFollowCounts(currentUserId, targetUserId);
return this.buildFollowActionResponse('Already following user', true, targetUserId, counts);
}
if (targetUser.isPrivate) {
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
const counts = await this.getFollowCounts(currentUserId, targetUserId);
return {
...this.buildFollowActionResponse('Follow request sent', false, targetUserId, counts),
requested: true,
requestId: request.id,
};
}
let followId: string | null = null;
try {
const follow = await this.followsRepository.create(currentUserId, targetUserId);
followId = follow.id;
} catch (error) {
if (!this.followsRepository.isDuplicateKeyError(error)) {
throw error;
}
}
const counts = await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
if (followId) {
await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
}
return this.buildFollowActionResponse('User followed successfully', true, targetUserId, counts);
}
async unfollowUser(currentUserId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot follow yourself');
}
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
if (existing) {
await this.followsRepository.deleteById(existing.id);
await this.feedVersionService.bumpGlobalVersion();
}
const counts = await this.syncFollowCounts(currentUserId, targetUserId);
return this.buildFollowActionResponse('User unfollowed successfully', false, targetUserId, counts);
}
async getFollowers(userId: string, query: PaginationQueryDto, viewerUserId?: string) {
await this.assertListTargetExists(userId);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findMany({ followingId: userId }, skip, limit, sort),
const [followRows, total] = await Promise.all([
this.followsRepository.findFollowerUserIds(userId, skip, limit, sort),
this.followsRepository.count({ followingId: userId }),
]);
const items = await this.decorateFollowUsers(
followRows.map((row) => row.followerId.toString()),
viewerUserId,
);
return buildPaginatedResponse(items, {
page,
@@ -86,15 +153,20 @@ export class FollowsService {
});
}
async getFollowing(userId: string, query: PaginationQueryDto) {
async getFollowing(userId: string, query: PaginationQueryDto, viewerUserId?: string) {
await this.assertListTargetExists(userId);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findMany({ followerId: userId }, skip, limit, sort),
const [followRows, total] = await Promise.all([
this.followsRepository.findFollowingUserIds(userId, skip, limit, sort),
this.followsRepository.count({ followerId: userId }),
]);
const items = await this.decorateFollowUsers(
followRows.map((row) => row.followingId.toString()),
viewerUserId,
);
return buildPaginatedResponse(items, {
page,
@@ -110,16 +182,28 @@ export class FollowsService {
}
if (currentUserId === targetUserId) {
return { following: false, targetUserId };
return {
following: false,
isFollowing: false,
isFollowedBy: false,
isMutual: false,
targetUserId,
};
}
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
const [existing, reverseExisting] = await Promise.all([
this.followsRepository.findOne(currentUserId, targetUserId),
this.followsRepository.findOne(targetUserId, currentUserId),
]);
const pendingRequest =
currentUserId === targetUserId
? null
: await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
return {
following: !!existing,
isFollowing: !!existing,
isFollowedBy: !!reverseExisting,
isMutual: !!existing && !!reverseExisting,
requested: !!pendingRequest,
targetUserId,
};
@@ -282,7 +366,10 @@ export class FollowsService {
return count;
}
private async syncFollowCounts(currentUserId: string, targetUserId: string): Promise<void> {
private async syncFollowCounts(
currentUserId: string,
targetUserId: string,
): Promise<{ followingCount: number; followersCount: number }> {
const [followingCount, followersCount] = await Promise.all([
this.followsRepository.count({ followerId: currentUserId }),
this.followsRepository.count({ followingId: targetUserId }),
@@ -292,5 +379,102 @@ export class FollowsService {
this.usersRepository.setFollowingCount(currentUserId, followingCount),
this.usersRepository.setFollowersCount(targetUserId, followersCount),
]);
return { followingCount, followersCount };
}
private async getFollowCounts(
currentUserId: string,
targetUserId: string,
): Promise<{ followingCount: number; followersCount: number }> {
const [followingCount, followersCount] = await Promise.all([
this.followsRepository.count({ followerId: currentUserId }),
this.followsRepository.count({ followingId: targetUserId }),
]);
return { followingCount, followersCount };
}
private async assertCanFollowTarget(currentUserId: string, targetUserId: string): Promise<void> {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot follow yourself');
}
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
if (block) {
throw new BadRequestException('You cannot follow this user');
}
}
private async findActiveTargetUser(targetUserId: string): Promise<UserDocument> {
const targetUser = await this.usersRepository.findById(targetUserId);
if (!targetUser || targetUser.isDisabled) {
throw new NotFoundException('Target user not found');
}
return targetUser;
}
private async assertListTargetExists(userId: string): Promise<void> {
if (!Types.ObjectId.isValid(userId)) {
throw new BadRequestException('Invalid user id');
}
await this.findActiveTargetUser(userId);
}
private async enqueueFollowNotification(actorId: string, recipientId: string, followId: string): Promise<void> {
try {
await this.outboxService.enqueueFollowNotification(actorId, recipientId, followId);
} catch (error) {
this.logger.warn(
`Follow notification failed for actor=${actorId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
private buildFollowActionResponse(
message: string,
isFollowing: boolean,
targetUserId: string,
counts: { followingCount: number; followersCount: number },
) {
return {
message,
following: isFollowing,
isFollowing,
targetUserId,
followersCount: counts.followersCount,
followingCount: counts.followingCount,
};
}
private async decorateFollowUsers(userIds: string[], viewerUserId?: string) {
const users = await this.usersRepository.findManyByIds(userIds);
const usersById = new Map(users.map((user) => [user.id, user]));
const viewerFollowingIds = viewerUserId ? new Set(await this.followsRepository.findFollowingIds(viewerUserId)) : new Set();
return userIds
.map((id) => usersById.get(id))
.filter((user): user is UserDocument => !!user)
.map((user) => {
const object = user.toObject();
return {
_id: object._id,
name: object.name,
stageName: object.stageName,
username: object.username,
avatar: object.avatar,
isVerified: object.isVerified,
isDisabled: object.isDisabled,
followersCount: object.followersCount ?? 0,
followingCount: object.followingCount ?? 0,
isFollowing: viewerFollowingIds.has(user.id),
};
});
}
}

عرض الملف

@@ -15,3 +15,5 @@ export class Follow {
export const FollowSchema = SchemaFactory.createForClass(Follow);
FollowSchema.index({ followerId: 1, followingId: 1 }, { unique: true });
FollowSchema.index({ followerId: 1, createdAt: -1 });
FollowSchema.index({ followingId: 1, createdAt: -1 });