feat: stabilize social backend contracts
هذا الالتزام موجود في:
@@ -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 });
|
||||
26
src/modules/devices/devices.controller.ts
Normal file
26
src/modules/devices/devices.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/modules/devices/devices.module.ts
Normal file
14
src/modules/devices/devices.module.ts
Normal file
@@ -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 {}
|
||||
45
src/modules/devices/devices.repository.ts
Normal file
45
src/modules/devices/devices.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
45
src/modules/devices/devices.service.ts
Normal file
45
src/modules/devices/devices.service.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/modules/devices/dto/register-device.dto.ts
Normal file
25
src/modules/devices/dto/register-device.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
src/modules/devices/dto/unregister-device.dto.ts
Normal file
13
src/modules/devices/dto/unregister-device.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
36
src/modules/devices/schemas/device.schema.ts
Normal file
36
src/modules/devices/schemas/device.schema.ts
Normal file
@@ -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 });
|
||||
58
src/modules/follows/follows-users.controller.ts
Normal file
58
src/modules/follows/follows-users.controller.ts
Normal file
@@ -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 });
|
||||
|
||||
24
src/modules/posts/posts-users.controller.ts
Normal file
24
src/modules/posts/posts-users.controller.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم