Add Instagram-style social features and Postman collections
هذا الالتزام موجود في:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, 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';
|
||||
@@ -49,4 +49,25 @@ export class FollowsController {
|
||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getSuggestions(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('requests')
|
||||
async requests(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getPendingRequests(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('requests/:requestId/approve')
|
||||
async approveRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||
return this.followsService.approveRequest(user.sub, requestId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('requests/:requestId/reject')
|
||||
async rejectRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||
return this.followsService.rejectRequest(user.sub, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
|
||||
import { FollowsController } from './follows.controller';
|
||||
import { FollowsService } from './follows.service';
|
||||
import { FollowsRepository } from './follows.repository';
|
||||
import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema';
|
||||
import { Follow, FollowSchema } from './schemas/follow.schema';
|
||||
|
||||
@Module({
|
||||
@@ -16,10 +17,14 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
|
||||
name: Follow.name,
|
||||
schema: FollowSchema,
|
||||
},
|
||||
{
|
||||
name: FollowRequest.name,
|
||||
schema: FollowRequestSchema,
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [FollowsController],
|
||||
providers: [FollowsService, FollowsRepository],
|
||||
exports: [FollowsService],
|
||||
exports: [FollowsService, FollowsRepository],
|
||||
})
|
||||
export class FollowsModule {}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||
import { FollowRequest, FollowRequestDocument } from './schemas/follow-request.schema';
|
||||
import { Follow, FollowDocument } from './schemas/follow.schema';
|
||||
|
||||
@Injectable()
|
||||
export class FollowsRepository {
|
||||
constructor(@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>) {}
|
||||
constructor(
|
||||
@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>,
|
||||
@InjectModel(FollowRequest.name)
|
||||
private readonly followRequestModel: Model<FollowRequestDocument>,
|
||||
) {}
|
||||
|
||||
async findOne(followerId: string, followingId: string): Promise<FollowDocument | null> {
|
||||
return this.followModel
|
||||
@@ -67,4 +72,66 @@ export class FollowsRepository {
|
||||
|
||||
return rows.map((row) => row.followingId.toString());
|
||||
}
|
||||
|
||||
async findPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument | null> {
|
||||
return this.followRequestModel
|
||||
.findOne({
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async upsertPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument> {
|
||||
return this.followRequestModel
|
||||
.findOneAndUpdate(
|
||||
{
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
},
|
||||
{
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
},
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateRequestStatus(
|
||||
requestId: string,
|
||||
targetUserId: string,
|
||||
status: 'approved' | 'rejected',
|
||||
): Promise<FollowRequestDocument | null> {
|
||||
return this.followRequestModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
|
||||
{ status },
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findPendingRequestsForTarget(
|
||||
targetUserId: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<FollowRequestDocument[]> {
|
||||
return this.followRequestModel
|
||||
.find({ targetUserId: new Types.ObjectId(targetUserId), status: 'pending' })
|
||||
.populate({ path: 'requesterId', select: 'name username stageName avatar isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countPendingRequestsForTarget(targetUserId: string): Promise<number> {
|
||||
return this.followRequestModel
|
||||
.countDocuments({ targetUserId: new Types.ObjectId(targetUserId), status: 'pending' })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export class FollowsService {
|
||||
return { following: false };
|
||||
}
|
||||
|
||||
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);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
@@ -109,12 +114,64 @@ export class FollowsService {
|
||||
}
|
||||
|
||||
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
|
||||
const pendingRequest =
|
||||
currentUserId === targetUserId
|
||||
? null
|
||||
: await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
|
||||
return {
|
||||
following: !!existing,
|
||||
requested: !!pendingRequest,
|
||||
targetUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async getPendingRequests(currentUserId: string, query: PaginationQueryDto) {
|
||||
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.findPendingRequestsForTarget(currentUserId, skip, limit, sort),
|
||||
this.followsRepository.countPendingRequestsForTarget(currentUserId),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
|
||||
}
|
||||
|
||||
async approveRequest(currentUserId: string, requestId: string) {
|
||||
if (!Types.ObjectId.isValid(requestId)) {
|
||||
throw new BadRequestException('Invalid follow request id');
|
||||
}
|
||||
|
||||
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'approved');
|
||||
if (!request) {
|
||||
throw new NotFoundException('Follow request not found');
|
||||
}
|
||||
|
||||
const requesterId = request.requesterId.toString();
|
||||
const existing = await this.followsRepository.findOne(requesterId, currentUserId);
|
||||
if (!existing) {
|
||||
await this.followsRepository.create(requesterId, currentUserId);
|
||||
await this.syncFollowCounts(requesterId, currentUserId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
}
|
||||
|
||||
return { approved: true, following: true, requesterId };
|
||||
}
|
||||
|
||||
async rejectRequest(currentUserId: string, requestId: string) {
|
||||
if (!Types.ObjectId.isValid(requestId)) {
|
||||
throw new BadRequestException('Invalid follow request id');
|
||||
}
|
||||
|
||||
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'rejected');
|
||||
if (!request) {
|
||||
throw new NotFoundException('Follow request not found');
|
||||
}
|
||||
|
||||
return { rejected: true, requesterId: request.requesterId.toString() };
|
||||
}
|
||||
|
||||
async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
21
src/modules/follows/schemas/follow-request.schema.ts
Normal file
21
src/modules/follows/schemas/follow-request.schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type FollowRequestDocument = HydratedDocument<FollowRequest>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class FollowRequest {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
requesterId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
targetUserId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
|
||||
status!: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export const FollowRequestSchema = SchemaFactory.createForClass(FollowRequest);
|
||||
FollowRequestSchema.index({ requesterId: 1, targetUserId: 1 }, { unique: true });
|
||||
FollowRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم