feat: stabilize social backend contracts

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

عرض الملف

@@ -16,6 +16,8 @@ import { AuditModule } from './modules/audit/audit.module';
import { BlocksModule } from './modules/blocks/blocks.module';
import { ChatModule } from './modules/chat/chat.module';
import { CommentsModule } from './modules/comments/comments.module';
import { CollaborationRequestsModule } from './modules/collaboration-requests/collaboration-requests.module';
import { DevicesModule } from './modules/devices/devices.module';
import { FeedModule } from './modules/feed/feed.module';
import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module';
@@ -50,6 +52,8 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
AuthModule,
PostsModule,
CommentsModule,
CollaborationRequestsModule,
DevicesModule,
LikesModule,
FollowsModule,
FeedModule,

عرض الملف

@@ -6,4 +6,7 @@ export enum NotificationType {
SAVE = 'save',
SHARE = 'share',
MENTION = 'mention',
REPLY = 'reply',
SYSTEM = 'system',
COLLABORATION_REQUEST = 'collaboration_request',
}

عرض الملف

@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { ChatRealtimeService } from './chat-realtime.service';
import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto';
import { UsersService } from '../users/users.service';
type SocketWithUser = Socket & { data: { userId?: string } };
@@ -21,12 +22,14 @@ type SocketWithUser = Socket & { data: { userId?: string } };
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly connectionCountsByUser = new Map<string, number>();
constructor(
private readonly chatService: ChatService,
private readonly chatRealtimeService: ChatRealtimeService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {}
afterInit(server: Server) {
@@ -49,6 +52,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return;
}
client.data.userId = payload.sub;
this.incrementUserConnection(payload.sub);
await this.usersService.setPresence(payload.sub, true);
await client.join(this.userRoom(payload.sub));
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
} catch {
@@ -59,7 +64,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: SocketWithUser) {
const userId = client.data.userId;
if (userId) {
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
const remainingConnections = this.decrementUserConnection(userId);
if (remainingConnections === 0) {
void this.usersService.setPresence(userId, false);
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
}
}
}
@@ -140,4 +149,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private conversationRoom(conversationId: string): string {
return `conversation:${conversationId}`;
}
private incrementUserConnection(userId: string): void {
this.connectionCountsByUser.set(userId, (this.connectionCountsByUser.get(userId) ?? 0) + 1);
}
private decrementUserConnection(userId: string): number {
const nextCount = Math.max(0, (this.connectionCountsByUser.get(userId) ?? 1) - 1);
if (nextCount === 0) {
this.connectionCountsByUser.delete(userId);
return 0;
}
this.connectionCountsByUser.set(userId, nextCount);
return nextCount;
}
}

عرض الملف

@@ -0,0 +1,39 @@
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 { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CollaborationRequestsService } from './collaboration-requests.service';
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto';
@ApiTags('Collaboration Requests')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class CollaborationRequestsController {
constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {}
@Post('posts/:postId/collaboration-requests')
async create(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@Body() dto: CreateCollaborationRequestDto,
) {
return this.collaborationRequestsService.create(user.sub, postId, dto.targetUserId);
}
@Get('collaboration-requests')
async mine(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.collaborationRequestsService.getMine(user.sub, query);
}
@Patch('collaboration-requests/:requestId/approve')
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.approve(user.sub, requestId);
}
@Patch('collaboration-requests/:requestId/reject')
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.reject(user.sub, requestId);
}
}

عرض الملف

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module';
import { UsersModule } from '../users/users.module';
import { CollaborationRequestsController } from './collaboration-requests.controller';
import { CollaborationRequestsService } from './collaboration-requests.service';
import {
CollaborationRequest,
CollaborationRequestSchema,
} from './schemas/collaboration-request.schema';
@Module({
imports: [
BlocksModule,
NotificationsModule,
PostsModule,
UsersModule,
MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]),
],
controllers: [CollaborationRequestsController],
providers: [CollaborationRequestsService],
})
export class CollaborationRequestsModule {}

عرض الملف

@@ -0,0 +1,136 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { BlocksRepository } from '../blocks/blocks.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository';
import { UsersRepository } from '../users/users.repository';
import {
CollaborationRequest,
CollaborationRequestDocument,
} from './schemas/collaboration-request.schema';
@Injectable()
export class CollaborationRequestsService {
constructor(
@InjectModel(CollaborationRequest.name)
private readonly collaborationRequestModel: Model<CollaborationRequestDocument>,
private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository,
private readonly blocksRepository: BlocksRepository,
private readonly notificationsService: NotificationsService,
) {}
async create(requesterId: string, postId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(postId) || !Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid collaboration request');
}
if (requesterId === targetUserId) {
throw new BadRequestException('You cannot invite yourself');
}
const [post, targetUser, block] = await Promise.all([
this.postsRepository.findById(postId),
this.usersRepository.findById(targetUserId),
this.blocksRepository.findAnyBetween(requesterId, targetUserId),
]);
if (!post) {
throw new NotFoundException('Post not found');
}
if (post.authorId.toString() !== requesterId) {
throw new ForbiddenException('Only the post owner can invite collaborators');
}
if (!targetUser || targetUser.isDisabled) {
throw new NotFoundException('Target user not found');
}
if (block) {
throw new BadRequestException('You cannot invite this user');
}
const filter = {
postId: new Types.ObjectId(postId),
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
};
const existing = await this.collaborationRequestModel.findOne(filter).exec();
const request = existing ?? await this.collaborationRequestModel
.findOneAndUpdate(
filter,
{
$setOnInsert: {
postId: new Types.ObjectId(postId),
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
},
},
{ new: true, upsert: true, setDefaultsOnInsert: true },
)
.exec();
if (!existing) {
await this.notificationsService.create({
actorId: requesterId,
recipientId: targetUserId,
type: 'collaboration_request',
referenceId: postId,
resourceType: 'post',
deepLink: `/posts/${postId}`,
});
}
return { message: 'Collaboration request sent', request };
}
async getMine(targetUserId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter = { targetUserId: new Types.ObjectId(targetUserId), status: 'pending' };
const [items, total] = await Promise.all([
this.collaborationRequestModel
.find(filter)
.populate({ path: 'requesterId', select: 'name username stageName avatar isVerified isDisabled' })
.populate({ path: 'postId' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.exec(),
this.collaborationRequestModel.countDocuments(filter).exec(),
]);
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
}
async approve(targetUserId: string, requestId: string) {
const request = await this.updateStatus(targetUserId, requestId, 'approved');
await this.postsRepository.updateById(request.postId.toString(), {
$addToSet: { collaboratorIds: request.targetUserId },
});
return { approved: true, request };
}
async reject(targetUserId: string, requestId: string) {
const request = await this.updateStatus(targetUserId, requestId, 'rejected');
return { rejected: true, request };
}
private async updateStatus(targetUserId: string, requestId: string, status: 'approved' | 'rejected') {
if (!Types.ObjectId.isValid(requestId)) {
throw new BadRequestException('Invalid collaboration request id');
}
const request = await this.collaborationRequestModel
.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
{ status },
{ new: true },
)
.exec();
if (!request) {
throw new NotFoundException('Collaboration request not found');
}
return request;
}
}

عرض الملف

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

عرض الملف

@@ -0,0 +1,25 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { Post } from '../../posts/schemas/post.schema';
import { User } from '../../users/schemas/user.schema';
export type CollaborationRequestDocument = HydratedDocument<CollaborationRequest>;
@Schema({ timestamps: true, versionKey: false })
export class CollaborationRequest {
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
postId!: Types.ObjectId;
@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({ enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
status!: 'pending' | 'approved' | 'rejected';
}
export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest);
CollaborationRequestSchema.index({ postId: 1, targetUserId: 1, status: 1 });
CollaborationRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });

عرض الملف

@@ -0,0 +1,26 @@
import { Body, Controller, 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 { RegisterDeviceDto } from './dto/register-device.dto';
import { UnregisterDeviceDto } from './dto/unregister-device.dto';
import { DevicesService } from './devices.service';
@ApiTags('Devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('devices')
export class DevicesController {
constructor(private readonly devicesService: DevicesService) {}
@Post('register')
async register(@CurrentUser() user: JwtPayload, @Body() dto: RegisterDeviceDto) {
return this.devicesService.register(user.sub, dto);
}
@Post('unregister')
async unregister(@CurrentUser() user: JwtPayload, @Body() dto: UnregisterDeviceDto) {
return this.devicesService.unregister(user.sub, dto);
}
}

عرض الملف

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { DevicesController } from './devices.controller';
import { DevicesRepository } from './devices.repository';
import { DevicesService } from './devices.service';
import { Device, DeviceSchema } from './schemas/device.schema';
@Module({
imports: [MongooseModule.forFeature([{ name: Device.name, schema: DeviceSchema }])],
controllers: [DevicesController],
providers: [DevicesService, DevicesRepository],
exports: [DevicesService, DevicesRepository],
})
export class DevicesModule {}

عرض الملف

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { Device, DeviceDocument } from './schemas/device.schema';
@Injectable()
export class DevicesRepository {
constructor(@InjectModel(Device.name) private readonly deviceModel: Model<DeviceDocument>) {}
async upsert(userId: string, payload: Omit<Partial<Device>, 'userId'>): Promise<DeviceDocument> {
const filter = payload.deviceId
? { userId: new Types.ObjectId(userId), deviceId: payload.deviceId }
: { userId: new Types.ObjectId(userId), fcmToken: payload.fcmToken };
return this.deviceModel
.findOneAndUpdate(
filter,
{
$set: {
...payload,
userId: new Types.ObjectId(userId),
isActive: true,
lastSeenAt: new Date(),
},
},
{ new: true, upsert: true, setDefaultsOnInsert: true },
)
.exec();
}
async deactivate(userId: string, payload: { fcmToken?: string; deviceId?: string }): Promise<DeviceDocument | null> {
const filter: Record<string, unknown> = { userId: new Types.ObjectId(userId) };
if (payload.deviceId) {
filter.deviceId = payload.deviceId;
} else if (payload.fcmToken) {
filter.fcmToken = payload.fcmToken;
} else {
return null;
}
return this.deviceModel
.findOneAndUpdate(filter, { isActive: false, lastSeenAt: new Date() }, { new: true })
.exec();
}
}

عرض الملف

@@ -0,0 +1,45 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { RegisterDeviceDto } from './dto/register-device.dto';
import { UnregisterDeviceDto } from './dto/unregister-device.dto';
import { DevicesRepository } from './devices.repository';
@Injectable()
export class DevicesService {
constructor(private readonly devicesRepository: DevicesRepository) {}
async register(userId: string, dto: RegisterDeviceDto) {
const fcmToken = dto.fcmToken.trim();
if (!fcmToken) {
throw new BadRequestException('fcmToken is required');
}
const device = await this.devicesRepository.upsert(userId, {
fcmToken,
platform: dto.platform,
deviceId: dto.deviceId?.trim() ?? '',
appVersion: dto.appVersion?.trim() ?? '',
locale: dto.locale?.trim() ?? '',
});
return {
message: 'Device registered successfully',
device,
};
}
async unregister(userId: string, dto: UnregisterDeviceDto) {
if (!dto.deviceId?.trim() && !dto.fcmToken?.trim()) {
throw new BadRequestException('deviceId or fcmToken is required');
}
const device = await this.devicesRepository.deactivate(userId, {
deviceId: dto.deviceId?.trim(),
fcmToken: dto.fcmToken?.trim(),
});
return {
message: 'Device unregistered successfully',
device,
};
}
}

عرض الملف

@@ -0,0 +1,25 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
@MaxLength(4096)
fcmToken!: string;
@IsEnum(['android', 'ios', 'web'])
platform!: 'android' | 'ios' | 'web';
@IsOptional()
@IsString()
@MaxLength(160)
deviceId?: string;
@IsOptional()
@IsString()
@MaxLength(60)
appVersion?: string;
@IsOptional()
@IsString()
@MaxLength(20)
locale?: string;
}

عرض الملف

@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UnregisterDeviceDto {
@IsOptional()
@IsString()
@MaxLength(4096)
fcmToken?: string;
@IsOptional()
@IsString()
@MaxLength(160)
deviceId?: string;
}

عرض الملف

@@ -0,0 +1,36 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type DeviceDocument = HydratedDocument<Device>;
@Schema({ timestamps: true, versionKey: false })
export class Device {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ required: true, trim: true, index: true })
fcmToken!: string;
@Prop({ enum: ['android', 'ios', 'web'], required: true, index: true })
platform!: 'android' | 'ios' | 'web';
@Prop({ default: '', trim: true, index: true })
deviceId!: string;
@Prop({ default: '', trim: true })
appVersion!: string;
@Prop({ default: '', trim: true })
locale!: string;
@Prop({ default: true, index: true })
isActive!: boolean;
@Prop({ type: Date, default: null })
lastSeenAt?: Date | null;
}
export const DeviceSchema = SchemaFactory.createForClass(Device);
DeviceSchema.index({ userId: 1, fcmToken: 1 }, { unique: true });
DeviceSchema.index({ userId: 1, deviceId: 1 }, { sparse: true });

عرض الملف

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

عرض الملف

@@ -92,22 +92,28 @@ export class NotificationsRepository {
}
async markRead(recipientId: string, notificationId: string): Promise<NotificationDocument | null> {
const updated = await this.notificationModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(notificationId),
recipientId: new Types.ObjectId(recipientId),
},
{
read: true,
readAt: new Date(),
},
{ new: true },
)
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
const notification = await this.notificationModel
.findOne({
_id: new Types.ObjectId(notificationId),
recipientId: new Types.ObjectId(recipientId),
})
.exec();
return updated;
if (!notification) {
return null;
}
if (notification.read) {
await notification.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' });
return notification;
}
notification.read = true;
notification.readAt = notification.readAt ?? new Date();
await notification.save();
await notification.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' });
return notification;
}
async markAllRead(recipientId: string): Promise<number> {

عرض الملف

@@ -47,6 +47,7 @@ describe('NotificationsService', () => {
await expect(service.markAllRead('user-1')).resolves.toEqual({
message: 'All notifications marked as read',
modifiedCount: 4,
updatedCount: 4,
unreadCount: 2,
});
@@ -70,4 +71,68 @@ describe('NotificationsService', () => {
await expect(service.markRead('user-1', 'invalid-id')).rejects.toBeInstanceOf(NotFoundException);
expect(notificationsRepository.markRead).not.toHaveBeenCalled();
});
it('returns total unread count for the current user in the notifications list', async () => {
const notificationsRepository = {
findMine: jest.fn().mockResolvedValue([{ id: 'notification-1' }]),
countMine: jest.fn().mockResolvedValue(30),
countUnread: jest.fn().mockResolvedValue(7),
};
const notificationsGateway = {
emitUnreadCount: jest.fn(),
};
const service = new NotificationsService(
notificationsRepository as any,
notificationsGateway as any,
);
await expect(
service.getMine('507f1f77bcf86cd799439011', {
page: 1,
limit: 20,
sortOrder: 'desc' as any,
}),
).resolves.toMatchObject({
count: 1,
page: 1,
limit: 20,
total: 30,
unreadCount: 7,
pagination: {
mode: 'offset',
hasNextPage: true,
},
});
expect(notificationsRepository.countUnread).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
});
it('creates system notifications with system resource mapping when requested', async () => {
const notificationsRepository = {
create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }),
countUnread: jest.fn().mockResolvedValue(1),
};
const notificationsGateway = {
emitCreated: jest.fn(),
};
const service = new NotificationsService(
notificationsRepository as any,
notificationsGateway as any,
);
await service.create({
actorId: '507f1f77bcf86cd799439011',
recipientId: '507f191e810c19729de860ea',
type: 'system',
});
expect(notificationsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
type: 'system',
resourceType: 'system',
deepLink: '',
}),
);
});
});

عرض الملف

@@ -206,10 +206,13 @@ export class NotificationsService {
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const unreadFilter = { ...filter };
delete unreadFilter.read;
const [items, total, unreadCount] = await Promise.all([
this.notificationsRepository.findMany(filter, skip, limit, sort),
this.notificationsRepository.count(filter),
this.notificationsRepository.countUnreadAll(filter),
this.notificationsRepository.countUnreadAll(unreadFilter),
]);
return {
@@ -250,13 +253,14 @@ export class NotificationsService {
}
async markAllRead(recipientId: string) {
const updatedCount = await this.notificationsRepository.markAllRead(recipientId);
const modifiedCount = await this.notificationsRepository.markAllRead(recipientId);
const unreadCount = await this.notificationsRepository.countUnread(recipientId);
this.notificationsGateway.emitUnreadCount(recipientId, unreadCount);
return {
message: 'All notifications marked as read',
updatedCount,
modifiedCount,
updatedCount: modifiedCount,
unreadCount,
};
}
@@ -277,6 +281,12 @@ export class NotificationsService {
return 'Post shared';
case 'mention':
return 'New mention';
case 'reply':
return 'New reply';
case 'system':
return 'Notification';
case 'collaboration_request':
return 'Collaboration request';
default:
return 'Notification';
}
@@ -288,6 +298,10 @@ export class NotificationsService {
return 'user';
case 'message':
return 'conversation';
case 'system':
return 'system';
case 'collaboration_request':
return 'post';
default:
return 'post';
}
@@ -306,8 +320,8 @@ export class NotificationsService {
return `/users/${referenceId}`;
}
if (resourceType === 'comment' && referenceId) {
return `/comments/${referenceId}`;
if (resourceType === 'system') {
return '';
}
if (referenceId) {

عرض الملف

@@ -4,7 +4,18 @@ import { User } from '../../users/schemas/user.schema';
export type NotificationDocument = HydratedDocument<Notification>;
export const NOTIFICATION_TYPES = ['like', 'comment', 'follow', 'message', 'save', 'share', 'mention'] as const;
export const NOTIFICATION_TYPES = [
'like',
'comment',
'follow',
'message',
'save',
'share',
'mention',
'reply',
'system',
'collaboration_request',
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
@Schema({ timestamps: true, versionKey: false })
@@ -47,4 +58,6 @@ export const NotificationSchema = SchemaFactory.createForClass(Notification);
NotificationSchema.index({ recipientId: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 });
NotificationSchema.index({ referenceId: 1 });

عرض الملف

@@ -0,0 +1,24 @@
import { Controller, Get, Param, Query, 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 { PostQueryDto } from './dto/post-query.dto';
import { PostsService } from './posts.service';
@ApiTags('Users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users')
export class PostsUsersController {
constructor(private readonly postsService: PostsService) {}
@Get(':userId/posts')
async findUserPosts(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PostQueryDto,
) {
return this.postsService.findUserPosts(userId, query, user.sub);
}
}

عرض الملف

@@ -106,8 +106,12 @@ export class PostsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('user/:userId')
async findUserPosts(@Param('userId') userId: string, @Query() query: PostQueryDto) {
return this.postsService.findUserPosts(userId, query);
async findUserPosts(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PostQueryDto,
) {
return this.postsService.findUserPosts(userId, query, user.sub);
}
@ApiBearerAuth()

عرض الملف

@@ -5,6 +5,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { UsersModule } from '../users/users.module';
import { Post, PostSchema } from './schemas/post.schema';
import { PostsController } from './posts.controller';
import { PostsUsersController } from './posts-users.controller';
import { PostsRepository } from './posts.repository';
import { PostsService } from './posts.service';
@@ -20,7 +21,7 @@ import { PostsService } from './posts.service';
NotificationsModule,
UsersModule,
],
controllers: [PostsController],
controllers: [PostsController, PostsUsersController],
providers: [PostsService, PostsRepository],
exports: [PostsService, PostsRepository],
})

عرض الملف

@@ -1,6 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { extname } from 'path';
import { Types } from 'mongoose';
import { Connection, Types } from 'mongoose';
import { InjectConnection } from '@nestjs/mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum';
@@ -72,6 +73,7 @@ export class PostsService {
private readonly logger = new Logger(PostsService.name);
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository,
private readonly storageService: ManagedStorageService,
@@ -525,7 +527,7 @@ export class PostsService {
return post;
}
async findUserPosts(userId: string, query: PostQueryDto) {
async findUserPosts(userId: string, query: PostQueryDto, viewerUserId?: string) {
if (!Types.ObjectId.isValid(userId)) {
throw new BadRequestException('Invalid user id');
}
@@ -538,6 +540,21 @@ export class PostsService {
authorId: new Types.ObjectId(userId),
isArchived: { $ne: true },
};
if (viewerUserId && viewerUserId !== userId) {
const isBlocked = await this.hasBlockBetween(viewerUserId, userId);
if (isBlocked) {
return buildPaginatedResponse([], {
page,
limit,
total: 0,
offset: skip,
});
}
const isFollowing = await this.isFollowing(viewerUserId, userId);
filter.visibility = isFollowing
? { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] }
: PostVisibility.PUBLIC;
}
if (query.visibility) {
filter.visibility = query.visibility;
}
@@ -1602,4 +1619,30 @@ export class PostsService {
return '';
}
private async isFollowing(followerId: string, followingId: string): Promise<boolean> {
if (!Types.ObjectId.isValid(followerId) || !Types.ObjectId.isValid(followingId)) {
return false;
}
const count = await this.connection.collection('follows').countDocuments({
followerId: new Types.ObjectId(followerId),
followingId: new Types.ObjectId(followingId),
});
return count > 0;
}
private async hasBlockBetween(userA: string, userB: string): Promise<boolean> {
if (!Types.ObjectId.isValid(userA) || !Types.ObjectId.isValid(userB)) {
return false;
}
const userAId = new Types.ObjectId(userA);
const userBId = new Types.ObjectId(userB);
const count = await this.connection.collection('blocks').countDocuments({
$or: [
{ blockerId: userAId, blockedId: userBId },
{ blockerId: userBId, blockedId: userAId },
],
});
return count > 0;
}
}

عرض الملف

@@ -99,6 +99,12 @@ export class User {
@Prop({ default: false })
isEmailVerified!: boolean;
@Prop({ default: false, index: true })
isOnline!: boolean;
@Prop({ type: Date, default: null })
lastSeenAt?: Date | null;
@Prop({ default: '', trim: true, maxlength: 120 })
shopName!: string;

عرض الملف

@@ -288,11 +288,18 @@ export class UsersController {
return this.usersService.getProfileOverview(id, user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/presence')
async getPresence(@Param('id') id: string) {
return this.usersService.getPresence(id);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findPublicByIdOrFail(id);
async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.usersService.findPublicByIdForViewer(id, user.sub);
}
@ApiBearerAuth()

عرض الملف

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, UpdateQuery } from 'mongoose';
import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
import { User, UserDocument } from './schemas/user.schema';
@Injectable()
@@ -69,6 +69,12 @@ export class UsersRepository {
await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false }).exec();
}
async setPresence(userId: string, isOnline: boolean, lastSeenAt: Date): Promise<void> {
await this.userModel
.findByIdAndUpdate(userId, { isOnline, lastSeenAt }, { new: false })
.exec();
}
async findMany(
filter: FilterQuery<UserDocument>,
skip: number,
@@ -78,6 +84,16 @@ export class UsersRepository {
return this.userModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
}
async findManyByIds(ids: string[]): Promise<UserDocument[]> {
if (!ids.length) {
return [];
}
return this.userModel
.find({ _id: { $in: ids.map((id) => new Types.ObjectId(id)) } })
.exec();
}
async findByUsernames(usernames: string[]): Promise<UserDocument[]> {
if (!usernames.length) {
return [];

عرض الملف

@@ -237,6 +237,40 @@ export class UsersService {
return user;
}
async findPublicByIdForViewer(userId: string, viewerUserId: string) {
const user = await this.findPublicByIdOrFail(userId);
const isFollowing =
viewerUserId === userId
? false
: (await this.connection.collection('follows').countDocuments({
followerId: new Types.ObjectId(viewerUserId),
followingId: new Types.ObjectId(userId),
})) > 0;
return {
...user.toObject(),
isFollowing,
following: isFollowing,
isOwnProfile: viewerUserId === userId,
};
}
async getPresence(userId: string) {
const user = await this.findPublicByIdOrFail(userId);
return {
userId: user.id,
isOnline: user.isOnline ?? false,
lastSeenAt: user.lastSeenAt ?? null,
};
}
async setPresence(userId: string, isOnline: boolean): Promise<void> {
if (!Types.ObjectId.isValid(userId)) {
return;
}
await this.usersRepository.setPresence(userId, isOnline, new Date());
}
async findByEmailWithPassword(email: string): Promise<UserDocument | null> {
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
}
@@ -737,6 +771,7 @@ export class UsersService {
viewerState: {
isOwnProfile: viewerUserId === userId,
following: followingState,
isFollowing: followingState,
canMessage: viewerUserId !== userId,
},
};
@@ -749,6 +784,7 @@ export class UsersService {
viewerState: {
isOwnProfile: false,
following: false,
isFollowing: false,
canMessage: false,
},
};