Add Instagram-style social features and Postman collections
هذا الالتزام موجود في:
@@ -108,12 +108,96 @@ Supported filters:
|
||||
- `GET /notifications`
|
||||
- `read`, `type`, `resourceType`, `sortOrder`
|
||||
- `GET /comments/post/:postId`
|
||||
- `page`, `limit`, `sortOrder`
|
||||
- `page`, `limit`, `sortBy`, `sortOrder`
|
||||
- `sortBy`: `createdAt`, `top`
|
||||
- `POST /comments/:commentId/replies`
|
||||
- JSON body: `content`
|
||||
- the route `commentId` is the parent comment; clients do not send `postId`
|
||||
- `GET /comments/:commentId/replies`
|
||||
- `page`, `limit`, `sortBy`, `sortOrder`
|
||||
- `sortBy`: `createdAt`, `top`
|
||||
- `PATCH /comments/:commentId`
|
||||
- JSON body: `content`, `mentionUsernames`
|
||||
|
||||
Comment list items include Instagram-like UI fields:
|
||||
|
||||
- `repliesCount`
|
||||
- `repliesPreview`
|
||||
- `likesCount`
|
||||
- `likedByMe`
|
||||
- `canEdit`
|
||||
- `canDelete`
|
||||
- `replyToUser`
|
||||
|
||||
## WebSocket auth
|
||||
|
||||
## Social upgrades
|
||||
|
||||
### Reposts and quote posts
|
||||
|
||||
- `POST /posts/:postId/repost`
|
||||
- body `{ "content": "" }` creates a plain repost
|
||||
- body `{ "content": "My take" }` creates a quote post
|
||||
- repost items expose `repostOfPostId`; quote items expose `quoteOfPostId`
|
||||
|
||||
### Reactions
|
||||
|
||||
Existing likes endpoints now accept `reactionType`:
|
||||
|
||||
- `like`
|
||||
- `love`
|
||||
- `haha`
|
||||
- `wow`
|
||||
- `sad`
|
||||
- `angry`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetId": "...",
|
||||
"targetType": "post",
|
||||
"reactionType": "love"
|
||||
}
|
||||
```
|
||||
|
||||
`GET /likes/status/:targetType/:targetId` returns `reactionType` and `reactionSummary`.
|
||||
|
||||
### Private follow requests
|
||||
|
||||
When `targetUser.isPrivate=true`, `POST /follows/toggle` creates a pending request instead of following immediately.
|
||||
|
||||
- `GET /follows/requests`
|
||||
- `PATCH /follows/requests/:requestId/approve`
|
||||
- `PATCH /follows/requests/:requestId/reject`
|
||||
|
||||
`GET /follows/status/:targetUserId` returns `requested`.
|
||||
|
||||
### Reports
|
||||
|
||||
- `POST /reports`
|
||||
- `targetType`: `user`, `post`, `comment`, `listing`, `repair_shop`
|
||||
- `targetId`, `reason`, `details`
|
||||
- `GET /reports/me`
|
||||
- `GET /reports/superadmin`
|
||||
- `PATCH /reports/superadmin/:reportId/status`
|
||||
|
||||
### Blocks
|
||||
|
||||
Global block endpoints:
|
||||
|
||||
- `POST /blocks/:targetUserId`
|
||||
- `PATCH /blocks/:targetUserId/unblock`
|
||||
- `GET /blocks/status/:targetUserId`
|
||||
|
||||
Blocked users are excluded from feed/trending/explore visibility.
|
||||
|
||||
### Explore
|
||||
|
||||
- `GET /feed/explore`
|
||||
- public discovery feed using the trending ranking path
|
||||
- respects global block visibility
|
||||
|
||||
Both namespaces accept the JWT access token in one of these places:
|
||||
|
||||
- `auth.token`
|
||||
@@ -242,6 +326,41 @@ Recommended client behavior:
|
||||
- on normal networks, use `imageUrls[index]` or `imageVariants[index].mediumUrl`
|
||||
- on detail screens or zoom views, use `imageVariants[index].highUrl` or `imageVariants[index].originalUrl`
|
||||
|
||||
## Instagram-style social controls
|
||||
|
||||
Posts now support:
|
||||
|
||||
- carousel metadata through `imageItems[]` with `url`, `caption`, `altText`, and `order`
|
||||
- `collaboratorIds[]` on create/update
|
||||
- profile pinning through `PATCH /posts/:postId/pin-profile` and `/unpin-profile`
|
||||
- archive/restore through `PATCH /posts/:postId/archive` and `/restore-archive`
|
||||
- per-post comment settings through `PATCH /posts/:postId/comment-settings`
|
||||
|
||||
Comment settings:
|
||||
|
||||
- `commentsDisabled`
|
||||
- `commentsFollowersOnly`
|
||||
- `commentFilterKeywords[]`
|
||||
|
||||
Comments now support:
|
||||
|
||||
- `PATCH /comments/:commentId/pin`
|
||||
- `PATCH /comments/:commentId/unpin`
|
||||
- hidden offensive/filter matches via `hiddenByFilter`
|
||||
|
||||
Chat now supports:
|
||||
|
||||
- message media upload through `POST /chat/messages/upload`
|
||||
- replies with `replyToMessageId`
|
||||
- reactions through `PATCH /chat/messages/:messageId/reaction`
|
||||
- delete-for-me through `PATCH /chat/messages/:messageId/delete-for-me`
|
||||
|
||||
Reports now require fixed `reason` values:
|
||||
|
||||
- `spam`, `harassment`, `hate_speech`, `nudity`, `violence`, `scam`, `intellectual_property`, `self_harm`, `other`
|
||||
|
||||
When a post/comment reaches multiple open reports, the backend can automatically flag it for moderation. User reports can include `blockTarget: true` to block the reported user immediately.
|
||||
|
||||
## Marketplace split
|
||||
|
||||
Marketplace is now separated from musical instruments at the API contract level:
|
||||
|
||||
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
3573
postman/Oudelaa-Dashboard.postman_collection.json
Normal file
3573
postman/Oudelaa-Dashboard.postman_collection.json
Normal file
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
5835
postman/Oudelaa-Mobile.postman_collection.json
Normal file
5835
postman/Oudelaa-Mobile.postman_collection.json
Normal file
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
@@ -13,6 +13,7 @@ import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { StorageModule } from './infrastructure/storage/storage.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
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 { FeedModule } from './modules/feed/feed.module';
|
||||
@@ -23,6 +24,7 @@ import { MarketplaceModule } from './modules/marketplace/marketplace.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { OutboxModule } from './modules/outbox/outbox.module';
|
||||
import { PostsModule } from './modules/posts/posts.module';
|
||||
import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { SavesModule } from './modules/saves/saves.module';
|
||||
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -43,6 +45,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
QueueModule,
|
||||
DatabaseModule,
|
||||
AuditModule,
|
||||
BlocksModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
PostsModule,
|
||||
@@ -55,6 +58,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
ChatModule,
|
||||
MediaModule,
|
||||
MarketplaceModule,
|
||||
ReportsModule,
|
||||
SavesModule,
|
||||
SuperAdminModule,
|
||||
],
|
||||
|
||||
8
src/common/enums/reaction-type.enum.ts
Normal file
8
src/common/enums/reaction-type.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum ReactionType {
|
||||
LIKE = 'like',
|
||||
LOVE = 'love',
|
||||
HAHA = 'haha',
|
||||
WOW = 'wow',
|
||||
SAD = 'sad',
|
||||
ANGRY = 'angry',
|
||||
}
|
||||
11
src/common/enums/report-reason.enum.ts
Normal file
11
src/common/enums/report-reason.enum.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export enum ReportReason {
|
||||
SPAM = 'spam',
|
||||
HARASSMENT = 'harassment',
|
||||
HATE_SPEECH = 'hate_speech',
|
||||
NUDITY = 'nudity',
|
||||
VIOLENCE = 'violence',
|
||||
SCAM = 'scam',
|
||||
INTELLECTUAL_PROPERTY = 'intellectual_property',
|
||||
SELF_HARM = 'self_harm',
|
||||
OTHER = 'other',
|
||||
}
|
||||
29
src/modules/blocks/blocks.controller.ts
Normal file
29
src/modules/blocks/blocks.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Param, Patch, 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 { BlocksService } from './blocks.service';
|
||||
|
||||
@ApiTags('Blocks')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('blocks')
|
||||
export class BlocksController {
|
||||
constructor(private readonly blocksService: BlocksService) {}
|
||||
|
||||
@Post(':targetUserId')
|
||||
async block(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.blocksService.block(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Patch(':targetUserId/unblock')
|
||||
async unblock(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.blocksService.unblock(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Get('status/:targetUserId')
|
||||
async status(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.blocksService.getStatus(user.sub, targetUserId);
|
||||
}
|
||||
}
|
||||
15
src/modules/blocks/blocks.module.ts
Normal file
15
src/modules/blocks/blocks.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { BlocksController } from './blocks.controller';
|
||||
import { BlocksRepository } from './blocks.repository';
|
||||
import { BlocksService } from './blocks.service';
|
||||
import { Block, BlockSchema } from './schemas/block.schema';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, MongooseModule.forFeature([{ name: Block.name, schema: BlockSchema }])],
|
||||
controllers: [BlocksController],
|
||||
providers: [BlocksService, BlocksRepository],
|
||||
exports: [BlocksService, BlocksRepository],
|
||||
})
|
||||
export class BlocksModule {}
|
||||
83
src/modules/blocks/blocks.repository.ts
Normal file
83
src/modules/blocks/blocks.repository.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { Block, BlockDocument } from './schemas/block.schema';
|
||||
|
||||
@Injectable()
|
||||
export class BlocksRepository {
|
||||
constructor(@InjectModel(Block.name) private readonly blockModel: Model<BlockDocument>) {}
|
||||
|
||||
async findOne(blockerId: string, blockedId: string): Promise<BlockDocument | null> {
|
||||
return this.blockModel
|
||||
.findOne({
|
||||
blockerId: new Types.ObjectId(blockerId),
|
||||
blockedId: new Types.ObjectId(blockedId),
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findAnyBetween(userA: string, userB: string): Promise<BlockDocument | null> {
|
||||
return this.blockModel
|
||||
.findOne({
|
||||
$or: [
|
||||
{ blockerId: new Types.ObjectId(userA), blockedId: new Types.ObjectId(userB) },
|
||||
{ blockerId: new Types.ObjectId(userB), blockedId: new Types.ObjectId(userA) },
|
||||
],
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(blockerId: string, blockedId: string): Promise<void> {
|
||||
await this.blockModel
|
||||
.updateOne(
|
||||
{
|
||||
blockerId: new Types.ObjectId(blockerId),
|
||||
blockedId: new Types.ObjectId(blockedId),
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
blockerId: new Types.ObjectId(blockerId),
|
||||
blockedId: new Types.ObjectId(blockedId),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async remove(blockerId: string, blockedId: string): Promise<void> {
|
||||
await this.blockModel
|
||||
.deleteOne({
|
||||
blockerId: new Types.ObjectId(blockerId),
|
||||
blockedId: new Types.ObjectId(blockedId),
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findBlockedIds(blockerId: string): Promise<string[]> {
|
||||
const rows = await this.blockModel
|
||||
.find({ blockerId: new Types.ObjectId(blockerId) })
|
||||
.select({ blockedId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.blockedId.toString());
|
||||
}
|
||||
|
||||
async findBlockingOrBlockedIds(userId: string): Promise<string[]> {
|
||||
const userObjectId = new Types.ObjectId(userId);
|
||||
const rows = await this.blockModel
|
||||
.find({ $or: [{ blockerId: userObjectId }, { blockedId: userObjectId }] })
|
||||
.select({ blockerId: 1, blockedId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows.map((row) =>
|
||||
row.blockerId.toString() === userId ? row.blockedId.toString() : row.blockerId.toString(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/modules/blocks/blocks.service.ts
Normal file
64
src/modules/blocks/blocks.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { BlocksRepository } from './blocks.repository';
|
||||
|
||||
@Injectable()
|
||||
export class BlocksService {
|
||||
constructor(
|
||||
private readonly blocksRepository: BlocksRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async block(currentUserId: string, targetUserId: string) {
|
||||
this.assertValidTarget(currentUserId, targetUserId);
|
||||
const target = await this.usersRepository.findById(targetUserId);
|
||||
if (!target || target.isDisabled) {
|
||||
throw new NotFoundException('Target user not found');
|
||||
}
|
||||
|
||||
await this.blocksRepository.create(currentUserId, targetUserId);
|
||||
return { blocked: true, targetUserId };
|
||||
}
|
||||
|
||||
async unblock(currentUserId: string, targetUserId: string) {
|
||||
this.assertValidTarget(currentUserId, targetUserId);
|
||||
await this.blocksRepository.remove(currentUserId, targetUserId);
|
||||
return { blocked: false, targetUserId };
|
||||
}
|
||||
|
||||
async getStatus(currentUserId: string, targetUserId: string) {
|
||||
this.assertValidTarget(currentUserId, targetUserId);
|
||||
const [iBlocked, blockedMe] = await Promise.all([
|
||||
this.blocksRepository.findOne(currentUserId, targetUserId),
|
||||
this.blocksRepository.findOne(targetUserId, currentUserId),
|
||||
]);
|
||||
|
||||
return { targetUserId, iBlocked: !!iBlocked, blockedMe: !!blockedMe };
|
||||
}
|
||||
|
||||
async getBlockedIds(currentUserId: string): Promise<string[]> {
|
||||
return this.blocksRepository.findBlockedIds(currentUserId);
|
||||
}
|
||||
|
||||
async getInvisibleUserIds(currentUserId: string): Promise<string[]> {
|
||||
return this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
|
||||
}
|
||||
|
||||
async assertNoBlockBetween(currentUserId: string, targetUserId: string): Promise<void> {
|
||||
this.assertValidTarget(currentUserId, targetUserId);
|
||||
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
|
||||
if (block) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidTarget(currentUserId: string, targetUserId: string): void {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new BadRequestException('You cannot block yourself');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/modules/blocks/schemas/block.schema.ts
Normal file
17
src/modules/blocks/schemas/block.schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type BlockDocument = HydratedDocument<Block>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Block {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
blockerId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
blockedId!: Types.ObjectId;
|
||||
}
|
||||
|
||||
export const BlockSchema = SchemaFactory.createForClass(Block);
|
||||
BlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true });
|
||||
@@ -1,11 +1,24 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
import { MessageReactionDto } from './dto/message-reaction.dto';
|
||||
import { MessageQueryDto } from './dto/message-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@@ -42,6 +55,32 @@ export class ChatController {
|
||||
return this.chatService.sendMessage(user.sub, dto);
|
||||
}
|
||||
|
||||
@Post('messages/upload')
|
||||
@Throttle(60, 60_000)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'mediaFile', maxCount: 1 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
conversationId: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
replyToMessageId: { type: 'string' },
|
||||
mediaFile: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async sendMessageUpload(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: SendMessageDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
mediaFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.chatService.sendMessageWithUpload(user.sub, dto, files?.mediaFile?.[0]);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/seen')
|
||||
@Throttle(200, 60_000)
|
||||
async markSeen(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||
@@ -54,6 +93,22 @@ export class ChatController {
|
||||
return this.chatService.unsendMessage(user.sub, messageId);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/reaction')
|
||||
@Throttle(120, 60_000)
|
||||
async react(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('messageId') messageId: string,
|
||||
@Body() dto: MessageReactionDto,
|
||||
) {
|
||||
return this.chatService.reactToMessage(user.sub, messageId, dto.reactionType);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/delete-for-me')
|
||||
@Throttle(120, 60_000)
|
||||
async deleteForMe(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||
return this.chatService.deleteMessageForMe(user.sub, messageId);
|
||||
}
|
||||
|
||||
@Post('blocks/:targetUserId')
|
||||
@Throttle(20, 60_000)
|
||||
async blockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
@@ -17,6 +18,7 @@ import { Message, MessageSchema } from './schemas/message.schema';
|
||||
ConfigModule,
|
||||
JwtModule.register({}),
|
||||
NotificationsModule,
|
||||
StorageModule,
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Conversation.name, schema: ConversationSchema },
|
||||
|
||||
@@ -76,6 +76,7 @@ export class ChatRepository {
|
||||
content?: string;
|
||||
messageType: 'text' | 'image' | 'video' | 'audio';
|
||||
mediaUrl?: string;
|
||||
replyToMessageId?: string | null;
|
||||
}): Promise<MessageDocument> {
|
||||
return this.messageModel.create({
|
||||
conversationId: new Types.ObjectId(payload.conversationId),
|
||||
@@ -83,6 +84,7 @@ export class ChatRepository {
|
||||
content: payload.content ?? '',
|
||||
messageType: payload.messageType,
|
||||
mediaUrl: payload.mediaUrl ?? '',
|
||||
replyToMessageId: payload.replyToMessageId ? new Types.ObjectId(payload.replyToMessageId) : null,
|
||||
seenBy: [new Types.ObjectId(payload.senderId)],
|
||||
isUnsent: false,
|
||||
});
|
||||
@@ -90,21 +92,35 @@ export class ChatRepository {
|
||||
|
||||
async findMessages(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<MessageDocument[]> {
|
||||
return this.messageModel
|
||||
.find({ conversationId: new Types.ObjectId(conversationId) })
|
||||
.find({
|
||||
conversationId: new Types.ObjectId(conversationId),
|
||||
deletedForUserIds: { $ne: new Types.ObjectId(userId) },
|
||||
})
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||
.populate({
|
||||
path: 'replyToMessageId',
|
||||
select: 'content messageType mediaUrl senderId isUnsent',
|
||||
populate: { path: 'senderId', select: 'name username stageName avatar isVerified' },
|
||||
})
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countMessages(conversationId: string): Promise<number> {
|
||||
return this.messageModel.countDocuments({ conversationId: new Types.ObjectId(conversationId) }).exec();
|
||||
async countMessages(conversationId: string, userId: string): Promise<number> {
|
||||
return this.messageModel
|
||||
.countDocuments({
|
||||
conversationId: new Types.ObjectId(conversationId),
|
||||
deletedForUserIds: { $ne: new Types.ObjectId(userId) },
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findMessageById(messageId: string): Promise<MessageDocument | null> {
|
||||
@@ -117,6 +133,27 @@ export class ChatRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async setMessageReaction(
|
||||
messageId: string,
|
||||
userId: string,
|
||||
reactionType: string,
|
||||
): Promise<MessageDocument | null> {
|
||||
return this.messageModel
|
||||
.findByIdAndUpdate(
|
||||
messageId,
|
||||
{ $set: { [`reactionsByUser.${userId}`]: reactionType } },
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteMessageForUser(messageId: string, userId: string): Promise<void> {
|
||||
await this.messageModel
|
||||
.findByIdAndUpdate(messageId, { $addToSet: { deletedForUserIds: new Types.ObjectId(userId) } })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateConversationAfterNewMessage(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { extname } from 'path';
|
||||
import { ReactionType } from '../../common/enums/reaction-type.enum';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
@@ -18,6 +21,7 @@ export class ChatService {
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
) {}
|
||||
|
||||
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||
@@ -108,8 +112,8 @@ export class ChatService {
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findMessages(conversation.id, skip, limit, sort),
|
||||
this.chatRepository.countMessages(conversation.id),
|
||||
this.chatRepository.findMessages(conversation.id, currentUserId, skip, limit, sort),
|
||||
this.chatRepository.countMessages(conversation.id, currentUserId),
|
||||
]);
|
||||
|
||||
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
|
||||
@@ -142,12 +146,14 @@ export class ChatService {
|
||||
throw new BadRequestException('mediaUrl is required for non-text messages');
|
||||
}
|
||||
|
||||
const replyToMessageId = await this.resolveReplyToMessageId(dto.replyToMessageId, conversation.id);
|
||||
const message = await this.chatRepository.createMessage({
|
||||
conversationId: conversation.id,
|
||||
senderId: currentUserId,
|
||||
content,
|
||||
messageType,
|
||||
mediaUrl,
|
||||
replyToMessageId,
|
||||
});
|
||||
|
||||
const preview = messageType === 'text' ? content : `${messageType} message`;
|
||||
@@ -167,6 +173,36 @@ export class ChatService {
|
||||
return message;
|
||||
}
|
||||
|
||||
async sendMessageWithUpload(
|
||||
currentUserId: string,
|
||||
dto: SendMessageDto,
|
||||
file?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('mediaFile is required');
|
||||
}
|
||||
|
||||
const mediaType = this.resolveUploadedMessageType(file);
|
||||
const mediaUrl = await this.storageService.saveFile({
|
||||
folderSegments: ['chat', 'media'],
|
||||
extension: this.resolveMediaExtension(mediaType, file),
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
fileNamePrefix: 'message',
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.sendMessage(currentUserId, {
|
||||
...dto,
|
||||
messageType: mediaType,
|
||||
mediaUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.storageService.deleteFile(mediaUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async markMessageSeen(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
@@ -196,6 +232,29 @@ export class ChatService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reactToMessage(currentUserId: string, messageId: string, reactionType: ReactionType) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
await this.assertConversationMember(currentUserId, message.conversationId.toString());
|
||||
const updated = await this.chatRepository.setMessageReaction(messageId, currentUserId, reactionType);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteMessageForMe(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
await this.assertConversationMember(currentUserId, message.conversationId.toString());
|
||||
await this.chatRepository.deleteMessageForUser(messageId, currentUserId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async blockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
@@ -267,6 +326,65 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveReplyToMessageId(replyToMessageId: string | undefined, conversationId: string) {
|
||||
if (!replyToMessageId) {
|
||||
return null;
|
||||
}
|
||||
if (!Types.ObjectId.isValid(replyToMessageId)) {
|
||||
throw new BadRequestException('Invalid replyToMessageId');
|
||||
}
|
||||
const replyMessage = await this.chatRepository.findMessageById(replyToMessageId);
|
||||
if (!replyMessage || replyMessage.conversationId.toString() !== conversationId) {
|
||||
throw new BadRequestException('Reply message must belong to the same conversation');
|
||||
}
|
||||
return replyMessage.id;
|
||||
}
|
||||
|
||||
private resolveUploadedMessageType(file: { mimetype?: string; originalname?: string }) {
|
||||
if (file.mimetype?.startsWith('image/')) {
|
||||
return 'image' as const;
|
||||
}
|
||||
if (file.mimetype?.startsWith('video/')) {
|
||||
return 'video' as const;
|
||||
}
|
||||
if (file.mimetype?.startsWith('audio/')) {
|
||||
return 'audio' as const;
|
||||
}
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) {
|
||||
return 'image' as const;
|
||||
}
|
||||
if (['.mp4', '.mov', '.webm', '.mkv', '.avi'].includes(extension)) {
|
||||
return 'video' as const;
|
||||
}
|
||||
if (['.mp3', '.wav', '.m4a', '.aac', '.ogg'].includes(extension)) {
|
||||
return 'audio' as const;
|
||||
}
|
||||
throw new BadRequestException('mediaFile must be image, video, or audio');
|
||||
}
|
||||
|
||||
private resolveMediaExtension(
|
||||
mediaType: 'image' | 'video' | 'audio',
|
||||
file: { mimetype?: string; originalname?: string },
|
||||
): string {
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
const allowed = {
|
||||
image: new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']),
|
||||
video: new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']),
|
||||
audio: new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.webm']),
|
||||
}[mediaType];
|
||||
if (allowed.has(extension)) {
|
||||
return extension;
|
||||
}
|
||||
if (mediaType === 'image') {
|
||||
return file.mimetype === 'image/png' ? '.png' : file.mimetype === 'image/webp' ? '.webp' : '.jpg';
|
||||
}
|
||||
if (mediaType === 'video') {
|
||||
return file.mimetype === 'video/quicktime' ? '.mov' : file.mimetype === 'video/webm' ? '.webm' : '.mp4';
|
||||
}
|
||||
return file.mimetype === 'audio/webm' ? '.webm' : file.mimetype === 'audio/ogg' ? '.ogg' : '.mp3';
|
||||
}
|
||||
|
||||
private async dispatchMessageNotifications(
|
||||
actorId: string,
|
||||
participantIds: string[],
|
||||
|
||||
9
src/modules/chat/dto/message-reaction.dto.ts
Normal file
9
src/modules/chat/dto/message-reaction.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ReactionType } from '../../../common/enums/reaction-type.enum';
|
||||
|
||||
export class MessageReactionDto {
|
||||
@ApiProperty({ enum: ReactionType })
|
||||
@IsEnum(ReactionType)
|
||||
reactionType!: ReactionType;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
import { IsEnum, IsMongoId, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
@@ -20,4 +20,9 @@ export class SendMessageDto {
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
mediaUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
replyToMessageId?: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class Conversation {
|
||||
|
||||
@Prop({ type: Map, of: Number, default: {} })
|
||||
unreadCountByUser!: Map<string, number>;
|
||||
|
||||
}
|
||||
|
||||
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
|
||||
|
||||
@@ -22,11 +22,21 @@ export class Message {
|
||||
@Prop({ required: false, default: '' })
|
||||
mediaUrl!: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Message', default: null, index: true })
|
||||
replyToMessageId?: Types.ObjectId | null;
|
||||
|
||||
@Prop({ type: Map, of: String, default: {} })
|
||||
reactionsByUser!: Map<string, string>;
|
||||
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, default: [] })
|
||||
seenBy!: Types.ObjectId[];
|
||||
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
|
||||
deletedForUserIds!: Types.ObjectId[];
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isUnsent!: boolean;
|
||||
|
||||
}
|
||||
|
||||
export const MessageSchema = SchemaFactory.createForClass(Message);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateCommentReplyDto } from './dto/create-comment-reply.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
@@ -28,15 +29,48 @@ export class CommentsController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('post/:postId')
|
||||
async findByPost(@Param('postId') postId: string, @Query() query: CommentQueryDto) {
|
||||
return this.commentsService.findByPost(postId, query);
|
||||
async findByPost(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('postId') postId: string,
|
||||
@Query() query: CommentQueryDto,
|
||||
) {
|
||||
return this.commentsService.findByPost(user.sub, postId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':commentId/replies')
|
||||
async createReply(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() dto: CreateCommentReplyDto,
|
||||
) {
|
||||
return this.commentsService.createReply(user.sub, commentId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':commentId/replies')
|
||||
async findReplies(@Param('commentId') commentId: string, @Query() query: CommentQueryDto) {
|
||||
return this.commentsService.findReplies(commentId, query);
|
||||
async findReplies(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('commentId') commentId: string,
|
||||
@Query() query: CommentQueryDto,
|
||||
) {
|
||||
return this.commentsService.findReplies(user.sub, commentId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':commentId/pin')
|
||||
async pin(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.pin(user.sub, commentId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':commentId/unpin')
|
||||
async unpin(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.unpin(user.sub, commentId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AuditModule } from '../audit/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PostsModule } from '../posts/posts.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Like, LikeSchema } from '../likes/schemas/like.schema';
|
||||
import { FollowsModule } from '../follows/follows.module';
|
||||
import { Comment, CommentSchema } from './schemas/comment.schema';
|
||||
import { CommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
@@ -12,10 +14,14 @@ import { CommentsRepository } from './comments.repository';
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
||||
MongooseModule.forFeature([
|
||||
{ name: Comment.name, schema: CommentSchema },
|
||||
{ name: Like.name, schema: LikeSchema },
|
||||
]),
|
||||
PostsModule,
|
||||
NotificationsModule,
|
||||
UsersModule,
|
||||
FollowsModule,
|
||||
],
|
||||
controllers: [CommentsController],
|
||||
providers: [CommentsService, CommentsRepository],
|
||||
|
||||
@@ -2,16 +2,21 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { Like, LikeDocument } from '../likes/schemas/like.schema';
|
||||
import { Comment, CommentDocument } from './schemas/comment.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsRepository {
|
||||
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
||||
constructor(
|
||||
@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>,
|
||||
@InjectModel(Like.name) private readonly likeModel: Model<LikeDocument>,
|
||||
) {}
|
||||
|
||||
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||
return {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
hiddenByFilter: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
};
|
||||
}
|
||||
@@ -30,6 +35,8 @@ export class CommentsRepository {
|
||||
content: string;
|
||||
mentionUsernames?: string[];
|
||||
parentCommentId?: string;
|
||||
hiddenByFilter?: boolean;
|
||||
hiddenReason?: string;
|
||||
},
|
||||
session?: ClientSession,
|
||||
) {
|
||||
@@ -38,6 +45,8 @@ export class CommentsRepository {
|
||||
authorId: new Types.ObjectId(payload.authorId),
|
||||
content: payload.content,
|
||||
mentionUsernames: payload.mentionUsernames ?? [],
|
||||
hiddenByFilter: payload.hiddenByFilter ?? false,
|
||||
hiddenReason: payload.hiddenReason ?? '',
|
||||
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
||||
});
|
||||
|
||||
@@ -54,6 +63,17 @@ export class CommentsRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findByIdWithAuthor(commentId: string): Promise<CommentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.commentModel
|
||||
.findOne({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteById(commentId: string, deletedBy?: string, session?: ClientSession): Promise<boolean> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return false;
|
||||
@@ -104,6 +124,80 @@ export class CommentsRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async setPinned(commentId: string, isPinned: boolean): Promise<CommentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.commentModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } },
|
||||
{ isPinned },
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyTop(
|
||||
filter: FilterQuery<CommentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
createdAtDirection: 1 | -1 = -1,
|
||||
): Promise<CommentDocument[]> {
|
||||
const rows = await this.commentModel
|
||||
.aggregate<{ _id: Types.ObjectId }>([
|
||||
{ $match: this.withActiveFilter(filter) },
|
||||
{
|
||||
$lookup: {
|
||||
from: this.likeModel.collection.name,
|
||||
let: { commentId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: {
|
||||
$and: [
|
||||
{ $eq: ['$targetId', '$$commentId'] },
|
||||
{ $eq: ['$targetType', 'comment'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 1 } },
|
||||
],
|
||||
as: 'commentLikes',
|
||||
},
|
||||
},
|
||||
{ $addFields: { likesCountForSort: { $size: '$commentLikes' } } },
|
||||
{ $sort: { likesCountForSort: -1, createdAt: createdAtDirection } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
{ $project: { _id: 1 } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
const ids = rows.map((row) => row._id);
|
||||
if (!ids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docs = await this.commentModel
|
||||
.find({ _id: { $in: ids } })
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
const docsById = new Map(docs.map((doc) => [doc.id, doc]));
|
||||
|
||||
const orderedDocs: CommentDocument[] = [];
|
||||
for (const id of ids) {
|
||||
const doc = docsById.get(id.toString());
|
||||
if (doc) {
|
||||
orderedDocs.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedDocs;
|
||||
}
|
||||
|
||||
async findManyAdmin(
|
||||
filter: FilterQuery<CommentDocument>,
|
||||
skip: number,
|
||||
@@ -123,6 +217,86 @@ export class CommentsRepository {
|
||||
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
||||
}
|
||||
|
||||
async countRepliesByParentIds(parentCommentIds: string[]): Promise<Record<string, number>> {
|
||||
if (!parentCommentIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = await this.commentModel
|
||||
.aggregate<{ _id: Types.ObjectId; count: number }>([
|
||||
{
|
||||
$match: this.withActiveFilter({
|
||||
parentCommentId: { $in: parentCommentIds.map((id) => new Types.ObjectId(id)) },
|
||||
}),
|
||||
},
|
||||
{ $group: { _id: '$parentCommentId', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row._id.toString(), row.count]));
|
||||
}
|
||||
|
||||
async findReplyPreviewsByParentIds(
|
||||
parentCommentIds: string[],
|
||||
limitPerParent = 2,
|
||||
): Promise<Record<string, CommentDocument[]>> {
|
||||
if (!parentCommentIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const previews: Record<string, CommentDocument[]> = {};
|
||||
await Promise.all(
|
||||
parentCommentIds.map(async (parentCommentId) => {
|
||||
previews[parentCommentId] = await this.findMany(
|
||||
{ parentCommentId: new Types.ObjectId(parentCommentId) },
|
||||
0,
|
||||
limitPerParent,
|
||||
{ createdAt: 1 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return previews;
|
||||
}
|
||||
|
||||
async countLikesByCommentIds(commentIds: string[]): Promise<Record<string, number>> {
|
||||
if (!commentIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = await this.likeModel
|
||||
.aggregate<{ _id: Types.ObjectId; count: number }>([
|
||||
{
|
||||
$match: {
|
||||
targetType: 'comment',
|
||||
targetId: { $in: commentIds.map((id) => new Types.ObjectId(id)) },
|
||||
},
|
||||
},
|
||||
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row._id.toString(), row.count]));
|
||||
}
|
||||
|
||||
async findLikedCommentIds(userId: string, commentIds: string[]): Promise<string[]> {
|
||||
if (!commentIds.length || !Types.ObjectId.isValid(userId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.likeModel
|
||||
.find({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetType: 'comment',
|
||||
targetId: { $in: commentIds.map((id) => new Types.ObjectId(id)) },
|
||||
})
|
||||
.select({ targetId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.targetId.toString());
|
||||
}
|
||||
|
||||
async countAdmin(filter: FilterQuery<CommentDocument>): Promise<number> {
|
||||
return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec();
|
||||
}
|
||||
|
||||
@@ -6,13 +6,35 @@ import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { FollowsRepository } from '../follows/follows.repository';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||
import { CommentQueryDto, CommentSortBy } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateCommentReplyDto } from './dto/create-comment-reply.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentsRepository } from './comments.repository';
|
||||
import { CommentDocument } from './schemas/comment.schema';
|
||||
|
||||
type CommentAuthorSummary = {
|
||||
id: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
stageName?: string;
|
||||
isVerified?: boolean;
|
||||
};
|
||||
|
||||
type InstagramComment = Record<string, unknown> & {
|
||||
repliesCount: number;
|
||||
repliesPreview: InstagramComment[];
|
||||
likesCount: number;
|
||||
likedByMe: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
replyToUser: CommentAuthorSummary | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
@@ -25,6 +47,7 @@ export class CommentsService {
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly followsRepository: FollowsRepository,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateCommentDto) {
|
||||
@@ -32,6 +55,7 @@ export class CommentsService {
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
await this.assertCanComment(userId, post);
|
||||
|
||||
let parentRecipientId = '';
|
||||
if (dto.parentCommentId) {
|
||||
@@ -43,6 +67,7 @@ export class CommentsService {
|
||||
}
|
||||
|
||||
const content = dto.content.trim();
|
||||
const hiddenByFilter = this.matchesCommentFilter(content, post.commentFilterKeywords ?? []);
|
||||
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, content, userId);
|
||||
const comment = await this.commentsRepository.create({
|
||||
postId: dto.postId,
|
||||
@@ -50,6 +75,8 @@ export class CommentsService {
|
||||
content,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
parentCommentId: dto.parentCommentId,
|
||||
hiddenByFilter,
|
||||
hiddenReason: hiddenByFilter ? 'keyword_filter' : '',
|
||||
});
|
||||
await this.syncCommentsCount(dto.postId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
@@ -72,6 +99,58 @@ export class CommentsService {
|
||||
return comment;
|
||||
}
|
||||
|
||||
async pin(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
const post = await this.postsRepository.findById(comment.postId.toString());
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
if (this.extractEntityId(post.authorId) !== userId) {
|
||||
throw new ForbiddenException('Only the post owner can pin comments');
|
||||
}
|
||||
const updated = await this.commentsRepository.setPinned(commentId, true);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async unpin(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
const post = await this.postsRepository.findById(comment.postId.toString());
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
if (this.extractEntityId(post.authorId) !== userId) {
|
||||
throw new ForbiddenException('Only the post owner can unpin comments');
|
||||
}
|
||||
const updated = await this.commentsRepository.setPinned(commentId, false);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createReply(userId: string, parentCommentId: string, dto: CreateCommentReplyDto) {
|
||||
const parent = await this.commentsRepository.findById(parentCommentId);
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent comment not found');
|
||||
}
|
||||
|
||||
return this.create(userId, {
|
||||
postId: parent.postId.toString(),
|
||||
content: dto.content,
|
||||
mentionUsernames: dto.mentionUsernames,
|
||||
parentCommentId,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
@@ -155,7 +234,7 @@ export class CommentsService {
|
||||
return { success: true, message: 'Comment deleted by superadmin' };
|
||||
}
|
||||
|
||||
async findByPost(postId: string, query: CommentQueryDto) {
|
||||
async findByPost(viewerId: string, postId: string, query: CommentQueryDto) {
|
||||
if (!Types.ObjectId.isValid(postId)) {
|
||||
throw new BadRequestException('Invalid post id');
|
||||
}
|
||||
@@ -164,25 +243,28 @@ export class CommentsService {
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const postObjectId = new Types.ObjectId(postId);
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const sort = this.resolveCommentSort(query);
|
||||
const filter = {
|
||||
postId: postObjectId,
|
||||
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany(
|
||||
{
|
||||
postId: postObjectId,
|
||||
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||
},
|
||||
skip,
|
||||
limit,
|
||||
sort,
|
||||
),
|
||||
this.commentsRepository.count({
|
||||
postId: postObjectId,
|
||||
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||
}),
|
||||
query.sortBy === CommentSortBy.TOP
|
||||
? this.commentsRepository.findManyTop(
|
||||
filter,
|
||||
skip,
|
||||
limit,
|
||||
resolveMongoSortDirection(query.sortOrder),
|
||||
)
|
||||
: this.commentsRepository.findMany(filter, skip, limit, sort),
|
||||
this.commentsRepository.count(filter),
|
||||
]);
|
||||
const enrichedItems = await this.enrichComments(items, viewerId, {
|
||||
includeRepliesPreview: true,
|
||||
});
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
return buildPaginatedResponse(enrichedItems, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
@@ -190,7 +272,7 @@ export class CommentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
||||
async findReplies(viewerId: string, parentCommentId: string, query: CommentQueryDto) {
|
||||
if (!Types.ObjectId.isValid(parentCommentId)) {
|
||||
throw new BadRequestException('Invalid parent comment id');
|
||||
}
|
||||
@@ -199,14 +281,26 @@ export class CommentsService {
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const parentObjectId = new Types.ObjectId(parentCommentId);
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const sort = this.resolveCommentSort(query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort),
|
||||
const filter = { parentCommentId: parentObjectId };
|
||||
const [items, total, parent] = await Promise.all([
|
||||
query.sortBy === CommentSortBy.TOP
|
||||
? this.commentsRepository.findManyTop(
|
||||
filter,
|
||||
skip,
|
||||
limit,
|
||||
resolveMongoSortDirection(query.sortOrder),
|
||||
)
|
||||
: this.commentsRepository.findMany(filter, skip, limit, sort),
|
||||
this.commentsRepository.count({ parentCommentId: parentObjectId }),
|
||||
this.commentsRepository.findByIdWithAuthor(parentCommentId),
|
||||
]);
|
||||
const enrichedItems = await this.enrichComments(items, viewerId, {
|
||||
replyToUser: this.extractAuthorSummary(parent?.authorId),
|
||||
});
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
return buildPaginatedResponse(enrichedItems, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
@@ -286,6 +380,156 @@ export class CommentsService {
|
||||
await this.postsRepository.setCommentsCount(postId, totalComments);
|
||||
}
|
||||
|
||||
private resolveCommentSort(query: CommentQueryDto): Record<string, 1 | -1> {
|
||||
return { isPinned: -1, createdAt: resolveMongoSortDirection(query.sortOrder) };
|
||||
}
|
||||
|
||||
private async assertCanComment(userId: string, post: any): Promise<void> {
|
||||
if (post.commentsDisabled) {
|
||||
throw new ForbiddenException('Comments are disabled for this post');
|
||||
}
|
||||
|
||||
const authorId = this.extractEntityId(post.authorId);
|
||||
if (!post.commentsFollowersOnly || authorId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const followsAuthor = await this.followsRepository.findOne(userId, authorId);
|
||||
if (!followsAuthor) {
|
||||
throw new ForbiddenException('Only followers can comment on this post');
|
||||
}
|
||||
}
|
||||
|
||||
private matchesCommentFilter(content: string, keywords: string[] = []): boolean {
|
||||
const normalized = content.toLowerCase();
|
||||
return keywords
|
||||
.map((keyword) => keyword.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
private async enrichComments(
|
||||
comments: CommentDocument[],
|
||||
viewerId: string,
|
||||
options: {
|
||||
includeRepliesPreview?: boolean;
|
||||
replyToUser?: CommentAuthorSummary | null;
|
||||
} = {},
|
||||
): Promise<InstagramComment[]> {
|
||||
if (!comments.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const commentIds = comments.map((comment) => comment.id);
|
||||
const [repliesCountByCommentId, likesCountByCommentId, likedCommentIds, previewsByCommentId] =
|
||||
await Promise.all([
|
||||
this.commentsRepository.countRepliesByParentIds(commentIds),
|
||||
this.commentsRepository.countLikesByCommentIds(commentIds),
|
||||
this.commentsRepository.findLikedCommentIds(viewerId, commentIds),
|
||||
options.includeRepliesPreview
|
||||
? this.commentsRepository.findReplyPreviewsByParentIds(commentIds, 2)
|
||||
: Promise.resolve({} as Record<string, CommentDocument[]>),
|
||||
]);
|
||||
|
||||
const likedCommentIdSet = new Set(likedCommentIds);
|
||||
const previewCommentIds = Object.values(previewsByCommentId)
|
||||
.flat()
|
||||
.map((comment) => comment.id);
|
||||
const [previewLikesCountByCommentId, previewLikedCommentIds] = await Promise.all([
|
||||
this.commentsRepository.countLikesByCommentIds(previewCommentIds),
|
||||
this.commentsRepository.findLikedCommentIds(viewerId, previewCommentIds),
|
||||
]);
|
||||
const previewLikedCommentIdSet = new Set(previewLikedCommentIds);
|
||||
|
||||
return comments.map((comment) => {
|
||||
const commentId = comment.id;
|
||||
const repliesPreview = (previewsByCommentId[commentId] ?? []).map((reply) =>
|
||||
this.serializeComment(reply, {
|
||||
viewerId,
|
||||
likesCount: previewLikesCountByCommentId[reply.id] ?? 0,
|
||||
likedByMe: previewLikedCommentIdSet.has(reply.id),
|
||||
repliesCount: 0,
|
||||
repliesPreview: [],
|
||||
replyToUser: this.extractAuthorSummary(comment.authorId),
|
||||
}),
|
||||
);
|
||||
|
||||
return this.serializeComment(comment, {
|
||||
viewerId,
|
||||
likesCount: likesCountByCommentId[commentId] ?? 0,
|
||||
likedByMe: likedCommentIdSet.has(commentId),
|
||||
repliesCount: repliesCountByCommentId[commentId] ?? 0,
|
||||
repliesPreview,
|
||||
replyToUser: options.replyToUser ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private serializeComment(
|
||||
comment: CommentDocument,
|
||||
params: {
|
||||
viewerId: string;
|
||||
likesCount: number;
|
||||
likedByMe: boolean;
|
||||
repliesCount: number;
|
||||
repliesPreview: InstagramComment[];
|
||||
replyToUser: CommentAuthorSummary | null;
|
||||
},
|
||||
): InstagramComment {
|
||||
const plain: Record<string, unknown> =
|
||||
typeof comment.toObject === 'function'
|
||||
? (comment.toObject({ virtuals: true }) as Record<string, unknown>)
|
||||
: (comment as unknown as Record<string, unknown>);
|
||||
const authorId = this.extractEntityId(plain.authorId ?? comment.authorId);
|
||||
const isDeleted = Boolean(plain.isDeleted);
|
||||
|
||||
return {
|
||||
...plain,
|
||||
id: plain.id ?? comment.id,
|
||||
content: isDeleted ? 'This comment was deleted' : plain.content,
|
||||
repliesCount: params.repliesCount,
|
||||
repliesPreview: params.repliesPreview,
|
||||
likesCount: params.likesCount,
|
||||
likedByMe: params.likedByMe,
|
||||
canEdit: !isDeleted && authorId === params.viewerId,
|
||||
canDelete: !isDeleted && authorId === params.viewerId,
|
||||
replyToUser: params.replyToUser,
|
||||
};
|
||||
}
|
||||
|
||||
private extractAuthorSummary(value: unknown): CommentAuthorSummary | null {
|
||||
if (!value || value instanceof Types.ObjectId || typeof value === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as {
|
||||
_id?: unknown;
|
||||
id?: unknown;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
stageName?: string;
|
||||
isVerified?: boolean;
|
||||
};
|
||||
const id = this.extractEntityId(candidate);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: candidate.name,
|
||||
username: candidate.username,
|
||||
avatar: candidate.avatar,
|
||||
stageName: candidate.stageName,
|
||||
isVerified: candidate.isVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchCommentNotifications(
|
||||
actorId: string,
|
||||
postAuthorId: string,
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export enum CommentSortBy {
|
||||
CREATED_AT = 'createdAt',
|
||||
TOP = 'top',
|
||||
}
|
||||
|
||||
export class CommentQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: CommentSortBy,
|
||||
default: CommentSortBy.CREATED_AT,
|
||||
description: 'Use top for Instagram-like popular comments, or createdAt for chronological sorting',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsEnum(CommentSortBy)
|
||||
sortBy?: CommentSortBy = CommentSortBy.CREATED_AT;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Use asc to display oldest comments first, or desc for newest first',
|
||||
default: 'desc',
|
||||
|
||||
7
src/modules/comments/dto/create-comment-reply.dto.ts
Normal file
7
src/modules/comments/dto/create-comment-reply.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PickType } from '@nestjs/swagger';
|
||||
import { CreateCommentDto } from './create-comment.dto';
|
||||
|
||||
export class CreateCommentReplyDto extends PickType(CreateCommentDto, [
|
||||
'content',
|
||||
'mentionUsernames',
|
||||
] as const) {}
|
||||
@@ -37,6 +37,15 @@ export class Comment {
|
||||
@Prop({ default: false, index: true })
|
||||
isDeleted!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isPinned!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
hiddenByFilter!: boolean;
|
||||
|
||||
@Prop({ default: '', maxlength: 120 })
|
||||
hiddenReason!: string;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
deletedAt?: Date | null;
|
||||
|
||||
@@ -48,3 +57,4 @@ export const CommentSchema = SchemaFactory.createForClass(Comment);
|
||||
CommentSchema.index({ postId: 1, createdAt: -1 });
|
||||
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
|
||||
CommentSchema.index({ moderationStatus: 1, createdAt: -1 });
|
||||
CommentSchema.index({ postId: 1, isPinned: -1, createdAt: -1 });
|
||||
|
||||
@@ -24,4 +24,11 @@ export class FeedController {
|
||||
async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||
return this.feedService.getTrending(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('explore')
|
||||
async explore(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||
return this.feedService.getExplore(user.sub, query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { BlocksModule } from '../blocks/blocks.module';
|
||||
import { FollowsModule } from '../follows/follows.module';
|
||||
import { LikesModule } from '../likes/likes.module';
|
||||
import { MarketplaceModule } from '../marketplace/marketplace.module';
|
||||
@@ -14,6 +15,7 @@ import { FeedRepository } from './feed.repository';
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
BlocksModule,
|
||||
LikesModule,
|
||||
SavesModule,
|
||||
FollowsModule,
|
||||
|
||||
@@ -28,6 +28,7 @@ export class FeedRepository {
|
||||
const activeFilter: FilterQuery<PostDocument> = {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
};
|
||||
|
||||
return this.postModel
|
||||
@@ -48,7 +49,7 @@ export class FeedRepository {
|
||||
limit: number,
|
||||
): Promise<PostDocument[]> {
|
||||
return this.postModel
|
||||
.find({ ...filter, isDeleted: { $ne: true } })
|
||||
.find({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({
|
||||
shareCount: -1,
|
||||
@@ -65,6 +66,8 @@ export class FeedRepository {
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<PostDocument>): Promise<number> {
|
||||
return this.postModel.countDocuments({ ...filter, isDeleted: { $ne: true } }).exec();
|
||||
return this.postModel
|
||||
.countDocuments({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/curso
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { BlocksService } from '../blocks/blocks.service';
|
||||
import { FollowsService } from '../follows/follows.service';
|
||||
import { LikesRepository } from '../likes/likes.repository';
|
||||
import { MarketplaceService } from '../marketplace/marketplace.service';
|
||||
@@ -66,6 +67,7 @@ export class FeedService {
|
||||
private readonly savesRepository: SavesRepository,
|
||||
private readonly followsService: FollowsService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly blocksService: BlocksService,
|
||||
) {}
|
||||
|
||||
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
||||
@@ -104,8 +106,11 @@ export class FeedService {
|
||||
const radiusKm = query.radiusKm ?? 30;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
|
||||
const [followingIds, invisibleUserIds] = await Promise.all([
|
||||
this.feedRepository.findFollowingIds(currentUserId),
|
||||
this.blocksService.getInvisibleUserIds(currentUserId),
|
||||
]);
|
||||
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds);
|
||||
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
|
||||
// Keep the default home feed focused on followed accounts, but avoid an empty screen
|
||||
@@ -115,7 +120,7 @@ export class FeedService {
|
||||
typeof query.followingOnly === 'undefined' &&
|
||||
followingOnly
|
||||
) {
|
||||
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false);
|
||||
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds);
|
||||
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
}
|
||||
|
||||
@@ -177,6 +182,14 @@ export class FeedService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getExplore(currentUserId: string, query: FeedQueryDto) {
|
||||
return this.getTrending(currentUserId, {
|
||||
...query,
|
||||
followingOnly: false,
|
||||
includeSuggestions: false,
|
||||
});
|
||||
}
|
||||
|
||||
async getTrending(currentUserId: string, query: FeedQueryDto) {
|
||||
const cacheEnabled =
|
||||
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||
@@ -200,8 +213,14 @@ export class FeedService {
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const page = query.page ?? 1;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||
const [followingIds, invisibleUserIds] = await Promise.all([
|
||||
this.feedRepository.findFollowingIds(currentUserId),
|
||||
this.blocksService.getInvisibleUserIds(currentUserId),
|
||||
]);
|
||||
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
|
||||
if (invisibleUserIds.length) {
|
||||
trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) };
|
||||
}
|
||||
if (query.preferredPostType) {
|
||||
trendingFilter.postType = query.preferredPostType;
|
||||
}
|
||||
@@ -377,12 +396,13 @@ export class FeedService {
|
||||
currentUserId: string,
|
||||
followingIds: string[],
|
||||
followingOnly: boolean,
|
||||
invisibleUserIds: string[] = [],
|
||||
): Record<string, unknown> {
|
||||
const currentUserObjectId = new Types.ObjectId(currentUserId);
|
||||
const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id));
|
||||
|
||||
if (followingOnly) {
|
||||
return {
|
||||
const visibilityFilter = followingOnly
|
||||
? {
|
||||
$or: [
|
||||
{ authorId: currentUserObjectId },
|
||||
{
|
||||
@@ -390,10 +410,8 @@ export class FeedService {
|
||||
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
}
|
||||
: {
|
||||
$or: [
|
||||
{ visibility: PostVisibility.PUBLIC },
|
||||
{ authorId: currentUserObjectId },
|
||||
@@ -403,6 +421,17 @@ export class FeedService {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (!invisibleUserIds.length) {
|
||||
return visibilityFilter;
|
||||
}
|
||||
|
||||
return {
|
||||
$and: [
|
||||
visibilityFilter,
|
||||
{ authorId: { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private scorePost(input: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
@@ -49,4 +49,25 @@ export class FollowsController {
|
||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getSuggestions(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('requests')
|
||||
async requests(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getPendingRequests(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('requests/:requestId/approve')
|
||||
async approveRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||
return this.followsService.approveRequest(user.sub, requestId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('requests/:requestId/reject')
|
||||
async rejectRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||
return this.followsService.rejectRequest(user.sub, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
|
||||
import { FollowsController } from './follows.controller';
|
||||
import { FollowsService } from './follows.service';
|
||||
import { FollowsRepository } from './follows.repository';
|
||||
import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema';
|
||||
import { Follow, FollowSchema } from './schemas/follow.schema';
|
||||
|
||||
@Module({
|
||||
@@ -16,10 +17,14 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
|
||||
name: Follow.name,
|
||||
schema: FollowSchema,
|
||||
},
|
||||
{
|
||||
name: FollowRequest.name,
|
||||
schema: FollowRequestSchema,
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [FollowsController],
|
||||
providers: [FollowsService, FollowsRepository],
|
||||
exports: [FollowsService],
|
||||
exports: [FollowsService, FollowsRepository],
|
||||
})
|
||||
export class FollowsModule {}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||
import { FollowRequest, FollowRequestDocument } from './schemas/follow-request.schema';
|
||||
import { Follow, FollowDocument } from './schemas/follow.schema';
|
||||
|
||||
@Injectable()
|
||||
export class FollowsRepository {
|
||||
constructor(@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>) {}
|
||||
constructor(
|
||||
@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>,
|
||||
@InjectModel(FollowRequest.name)
|
||||
private readonly followRequestModel: Model<FollowRequestDocument>,
|
||||
) {}
|
||||
|
||||
async findOne(followerId: string, followingId: string): Promise<FollowDocument | null> {
|
||||
return this.followModel
|
||||
@@ -67,4 +72,66 @@ export class FollowsRepository {
|
||||
|
||||
return rows.map((row) => row.followingId.toString());
|
||||
}
|
||||
|
||||
async findPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument | null> {
|
||||
return this.followRequestModel
|
||||
.findOne({
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async upsertPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument> {
|
||||
return this.followRequestModel
|
||||
.findOneAndUpdate(
|
||||
{
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
},
|
||||
{
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
},
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateRequestStatus(
|
||||
requestId: string,
|
||||
targetUserId: string,
|
||||
status: 'approved' | 'rejected',
|
||||
): Promise<FollowRequestDocument | null> {
|
||||
return this.followRequestModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
|
||||
{ status },
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findPendingRequestsForTarget(
|
||||
targetUserId: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<FollowRequestDocument[]> {
|
||||
return this.followRequestModel
|
||||
.find({ targetUserId: new Types.ObjectId(targetUserId), status: 'pending' })
|
||||
.populate({ path: 'requesterId', select: 'name username stageName avatar isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countPendingRequestsForTarget(targetUserId: string): Promise<number> {
|
||||
return this.followRequestModel
|
||||
.countDocuments({ targetUserId: new Types.ObjectId(targetUserId), status: 'pending' })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export class FollowsService {
|
||||
return { following: false };
|
||||
}
|
||||
|
||||
if (targetUser.isPrivate) {
|
||||
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
|
||||
return { following: false, requested: true, requestId: request.id };
|
||||
}
|
||||
|
||||
const follow = await this.followsRepository.create(currentUserId, targetUserId);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
@@ -109,12 +114,64 @@ export class FollowsService {
|
||||
}
|
||||
|
||||
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
|
||||
const pendingRequest =
|
||||
currentUserId === targetUserId
|
||||
? null
|
||||
: await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
|
||||
return {
|
||||
following: !!existing,
|
||||
requested: !!pendingRequest,
|
||||
targetUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async getPendingRequests(currentUserId: string, query: PaginationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findPendingRequestsForTarget(currentUserId, skip, limit, sort),
|
||||
this.followsRepository.countPendingRequestsForTarget(currentUserId),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
|
||||
}
|
||||
|
||||
async approveRequest(currentUserId: string, requestId: string) {
|
||||
if (!Types.ObjectId.isValid(requestId)) {
|
||||
throw new BadRequestException('Invalid follow request id');
|
||||
}
|
||||
|
||||
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'approved');
|
||||
if (!request) {
|
||||
throw new NotFoundException('Follow request not found');
|
||||
}
|
||||
|
||||
const requesterId = request.requesterId.toString();
|
||||
const existing = await this.followsRepository.findOne(requesterId, currentUserId);
|
||||
if (!existing) {
|
||||
await this.followsRepository.create(requesterId, currentUserId);
|
||||
await this.syncFollowCounts(requesterId, currentUserId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
}
|
||||
|
||||
return { approved: true, following: true, requesterId };
|
||||
}
|
||||
|
||||
async rejectRequest(currentUserId: string, requestId: string) {
|
||||
if (!Types.ObjectId.isValid(requestId)) {
|
||||
throw new BadRequestException('Invalid follow request id');
|
||||
}
|
||||
|
||||
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'rejected');
|
||||
if (!request) {
|
||||
throw new NotFoundException('Follow request not found');
|
||||
}
|
||||
|
||||
return { rejected: true, requesterId: request.requesterId.toString() };
|
||||
}
|
||||
|
||||
async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
21
src/modules/follows/schemas/follow-request.schema.ts
Normal file
21
src/modules/follows/schemas/follow-request.schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type FollowRequestDocument = HydratedDocument<FollowRequest>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class FollowRequest {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
requesterId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
targetUserId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
|
||||
status!: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export const FollowRequestSchema = SchemaFactory.createForClass(FollowRequest);
|
||||
FollowRequestSchema.index({ requesterId: 1, targetUserId: 1 }, { unique: true });
|
||||
FollowRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsMongoId } from 'class-validator';
|
||||
import { IsEnum, IsIn, IsMongoId, IsOptional } from 'class-validator';
|
||||
import { ReactionType } from '../../../common/enums/reaction-type.enum';
|
||||
|
||||
export class ToggleLikeDto {
|
||||
@ApiProperty()
|
||||
@@ -9,4 +10,9 @@ export class ToggleLikeDto {
|
||||
@ApiProperty({ enum: ['post', 'comment'] })
|
||||
@IsIn(['post', 'comment'])
|
||||
targetType!: 'post' | 'comment';
|
||||
|
||||
@ApiProperty({ enum: ReactionType, required: false, default: ReactionType.LIKE })
|
||||
@IsOptional()
|
||||
@IsEnum(ReactionType)
|
||||
reactionType?: ReactionType;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { ReactionType } from '../../common/enums/reaction-type.enum';
|
||||
import { Like, LikeDocument } from './schemas/like.schema';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,14 +18,24 @@ export class LikesRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(userId: string, targetId: string, targetType: 'post' | 'comment'): Promise<LikeDocument> {
|
||||
async create(
|
||||
userId: string,
|
||||
targetId: string,
|
||||
targetType: 'post' | 'comment',
|
||||
reactionType: ReactionType = ReactionType.LIKE,
|
||||
): Promise<LikeDocument> {
|
||||
return this.likeModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
targetType,
|
||||
reactionType,
|
||||
});
|
||||
}
|
||||
|
||||
async updateReaction(id: string, reactionType: ReactionType): Promise<LikeDocument | null> {
|
||||
return this.likeModel.findByIdAndUpdate(id, { reactionType }, { new: true }).exec();
|
||||
}
|
||||
|
||||
async findLikedPostIds(userId: string, postIds: string[]): Promise<string[]> {
|
||||
if (!postIds.length) {
|
||||
return [];
|
||||
@@ -46,4 +57,20 @@ export class LikesRepository {
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.likeModel.findByIdAndDelete(id).exec();
|
||||
}
|
||||
|
||||
async getReactionSummary(targetId: string, targetType: 'post' | 'comment') {
|
||||
const rows = await this.likeModel
|
||||
.aggregate<{ _id: ReactionType; count: number }>([
|
||||
{
|
||||
$match: {
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
targetType,
|
||||
},
|
||||
},
|
||||
{ $group: { _id: '$reactionType', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row._id ?? ReactionType.LIKE, row.count]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { ReactionType } from '../../common/enums/reaction-type.enum';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { CommentsRepository } from '../comments/comments.repository';
|
||||
@@ -19,21 +20,31 @@ export class LikesService {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
async toggle(userId: string, dto: ToggleLikeDto) {
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
return existing ? this.unlike(userId, dto) : this.like(userId, dto);
|
||||
}
|
||||
|
||||
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
async like(userId: string, dto: ToggleLikeDto) {
|
||||
await this.assertTargetExists(dto);
|
||||
const notificationContext = await this.resolveNotificationContext(dto);
|
||||
const reactionType = dto.reactionType ?? ReactionType.LIKE;
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
if (existing) {
|
||||
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
||||
if ((existing.reactionType ?? ReactionType.LIKE) !== reactionType) {
|
||||
await this.likesRepository.updateReaction(existing.id, reactionType);
|
||||
}
|
||||
return {
|
||||
liked: true,
|
||||
reacted: true,
|
||||
targetId: dto.targetId,
|
||||
targetType: dto.targetType,
|
||||
reactionType,
|
||||
};
|
||||
}
|
||||
|
||||
await this.likesRepository.create(userId, dto.targetId, dto.targetType);
|
||||
await this.likesRepository.create(userId, dto.targetId, dto.targetType, reactionType);
|
||||
if (dto.targetType === 'post') {
|
||||
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
|
||||
}
|
||||
@@ -58,15 +69,15 @@ export class LikesService {
|
||||
}
|
||||
}
|
||||
|
||||
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
||||
return { liked: true, reacted: true, targetId: dto.targetId, targetType: dto.targetType, reactionType };
|
||||
}
|
||||
|
||||
async unlike(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
async unlike(userId: string, dto: ToggleLikeDto) {
|
||||
await this.assertTargetExists(dto);
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
if (!existing) {
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
await this.likesRepository.deleteById(existing.id);
|
||||
@@ -75,17 +86,27 @@ export class LikesService {
|
||||
}
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
async getStatus(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
async getStatus(userId: string, dto: ToggleLikeDto) {
|
||||
const targetExists = await this.targetExists(dto);
|
||||
if (!targetExists) {
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
return { liked: !!existing, targetId: dto.targetId, targetType: dto.targetType };
|
||||
const [existing, reactionSummary] = await Promise.all([
|
||||
this.likesRepository.findOne(userId, dto.targetId, dto.targetType),
|
||||
this.likesRepository.getReactionSummary(dto.targetId, dto.targetType),
|
||||
]);
|
||||
return {
|
||||
liked: !!existing,
|
||||
reacted: !!existing,
|
||||
targetId: dto.targetId,
|
||||
targetType: dto.targetType,
|
||||
reactionType: existing?.reactionType ?? null,
|
||||
reactionSummary,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertTargetExists(dto: ToggleLikeDto): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { ReactionType } from '../../../common/enums/reaction-type.enum';
|
||||
|
||||
export type LikeDocument = HydratedDocument<Like>;
|
||||
|
||||
@@ -13,6 +14,9 @@ export class Like {
|
||||
|
||||
@Prop({ required: true, enum: ['post', 'comment'] })
|
||||
targetType!: 'post' | 'comment';
|
||||
|
||||
@Prop({ type: String, enum: Object.values(ReactionType), default: ReactionType.LIKE, index: true })
|
||||
reactionType!: ReactionType;
|
||||
}
|
||||
|
||||
export const LikeSchema = SchemaFactory.createForClass(Like);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsMongoId,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
@@ -82,6 +83,24 @@ export class CreatePostDto {
|
||||
@IsUrl({ require_tld: false }, { each: true })
|
||||
imageUrls?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Caption per carousel image, matched by order' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
@Length(0, 300, { each: true })
|
||||
imageCaptions?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Alt text per carousel image, matched by order' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
@Length(0, 300, { each: true })
|
||||
imageAltTexts?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Tagged user ids (max 20)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@@ -90,6 +109,14 @@ export class CreatePostDto {
|
||||
@IsMongoId({ each: true })
|
||||
taggedUserIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Collaborator user ids (max 5)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(5)
|
||||
@IsMongoId({ each: true })
|
||||
collaboratorIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@@ -123,4 +150,14 @@ export class CreatePostDto {
|
||||
@IsOptional()
|
||||
@IsEnum(PostVisibility)
|
||||
visibility?: PostVisibility;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
commentsDisabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
commentsFollowersOnly?: boolean;
|
||||
}
|
||||
|
||||
15
src/modules/posts/dto/create-repost.dto.ts
Normal file
15
src/modules/posts/dto/create-repost.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
import { CreatePostDto } from './create-post.dto';
|
||||
|
||||
export class CreateRepostDto {
|
||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Optional quote text. Empty content creates a repost.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 2200)
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['public', 'followers', 'private'] })
|
||||
@IsOptional()
|
||||
visibility?: CreatePostDto['visibility'];
|
||||
}
|
||||
25
src/modules/posts/dto/update-comment-settings.dto.ts
Normal file
25
src/modules/posts/dto/update-comment-settings.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ArrayMaxSize, IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||
|
||||
export class UpdateCommentSettingsDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
commentsDisabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
commentsFollowersOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
@Length(1, 40, { each: true })
|
||||
commentFilterKeywords?: string[];
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsMongoId,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
@@ -82,6 +83,24 @@ export class UpdatePostDto {
|
||||
@IsUrl({ require_tld: false }, { each: true })
|
||||
imageUrls?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Caption per carousel image, matched by order' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
@Length(0, 300, { each: true })
|
||||
imageCaptions?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Alt text per carousel image, matched by order' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
@Length(0, 300, { each: true })
|
||||
imageAltTexts?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Set tagged user ids (max 20)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@@ -90,6 +109,14 @@ export class UpdatePostDto {
|
||||
@IsMongoId({ each: true })
|
||||
taggedUserIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Collaborator user ids (max 5)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(5)
|
||||
@IsMongoId({ each: true })
|
||||
collaboratorIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Set mention usernames like rami_sabry (max 30)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@@ -123,4 +150,14 @@ export class UpdatePostDto {
|
||||
@IsOptional()
|
||||
@IsEnum(PostVisibility)
|
||||
visibility?: PostVisibility;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
commentsDisabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
commentsFollowersOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ import { SuperAdminPermissions } from '../../common/decorators/superadmin-permis
|
||||
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
|
||||
import { CreateReelDto } from './dto/create-reel.dto';
|
||||
import { CreatePostDto } from './dto/create-post.dto';
|
||||
import { CreateRepostDto } from './dto/create-repost.dto';
|
||||
import { PostQueryDto } from './dto/post-query.dto';
|
||||
import { ReelQueryDto } from './dto/reel-query.dto';
|
||||
import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto';
|
||||
import { UpdatePostDto } from './dto/update-post.dto';
|
||||
import { PostsService } from './posts.service';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
@@ -53,7 +55,10 @@ export class PostsController {
|
||||
content: { type: 'string', example: 'First post #music' },
|
||||
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageCaptions: { type: 'array', items: { type: 'string' } },
|
||||
imageAltTexts: { type: 'array', items: { type: 'string' } },
|
||||
taggedUserIds: { type: 'array', items: { type: 'string' } },
|
||||
collaboratorIds: { type: 'array', items: { type: 'string' } },
|
||||
mentionUsernames: { type: 'array', items: { type: 'string' } },
|
||||
location: { type: 'string', example: 'Riyadh, Saudi Arabia' },
|
||||
latitude: { type: 'number', example: 24.7136 },
|
||||
@@ -66,6 +71,8 @@ export class PostsController {
|
||||
maqam: { type: 'string', example: 'Hijaz' },
|
||||
rhythmSignature: { type: 'string', example: '6/8' },
|
||||
waveformPeaks: { type: 'array', items: { type: 'number' } },
|
||||
commentsDisabled: { type: 'boolean' },
|
||||
commentsFollowersOnly: { type: 'boolean' },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
videoFile: { type: 'string', format: 'binary' },
|
||||
audioFile: { type: 'string', format: 'binary' },
|
||||
@@ -175,7 +182,10 @@ export class PostsController {
|
||||
content: { type: 'string', example: 'Updated content' },
|
||||
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageCaptions: { type: 'array', items: { type: 'string' } },
|
||||
imageAltTexts: { type: 'array', items: { type: 'string' } },
|
||||
taggedUserIds: { type: 'array', items: { type: 'string' } },
|
||||
collaboratorIds: { type: 'array', items: { type: 'string' } },
|
||||
mentionUsernames: { type: 'array', items: { type: 'string' } },
|
||||
location: { type: 'string', example: 'Jeddah, Saudi Arabia' },
|
||||
latitude: { type: 'number', example: 21.5433 },
|
||||
@@ -188,6 +198,8 @@ export class PostsController {
|
||||
maqam: { type: 'string', example: 'Hijaz' },
|
||||
rhythmSignature: { type: 'string', example: '6/8' },
|
||||
waveformPeaks: { type: 'array', items: { type: 'number' } },
|
||||
commentsDisabled: { type: 'boolean' },
|
||||
commentsFollowersOnly: { type: 'boolean' },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
videoFile: { type: 'string', format: 'binary' },
|
||||
audioFile: { type: 'string', format: 'binary' },
|
||||
@@ -216,6 +228,56 @@ export class PostsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':postId/repost')
|
||||
async repost(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('postId') postId: string,
|
||||
@Body() dto: CreateRepostDto,
|
||||
) {
|
||||
return this.postsService.createRepost(user.sub, postId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':postId/comment-settings')
|
||||
async updateCommentSettings(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('postId') postId: string,
|
||||
@Body() dto: UpdateCommentSettingsDto,
|
||||
) {
|
||||
return this.postsService.updateCommentSettings(user.sub, postId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':postId/pin-profile')
|
||||
async pinToProfile(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
|
||||
return this.postsService.pinToProfile(user.sub, postId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':postId/unpin-profile')
|
||||
async unpinFromProfile(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
|
||||
return this.postsService.unpinFromProfile(user.sub, postId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':postId/archive')
|
||||
async archive(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
|
||||
return this.postsService.archive(user.sub, postId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':postId/restore-archive')
|
||||
async restoreArchive(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
|
||||
return this.postsService.restoreArchived(user.sub, postId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':postId')
|
||||
|
||||
@@ -12,6 +12,7 @@ export class PostsRepository {
|
||||
return {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
};
|
||||
}
|
||||
@@ -39,6 +40,11 @@ export class PostsRepository {
|
||||
.findOne({ _id: new Types.ObjectId(postId), isDeleted: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({
|
||||
path: 'repostOfPostId quoteOfPostId',
|
||||
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
@@ -51,6 +57,11 @@ export class PostsRepository {
|
||||
.findByIdAndUpdate(postId, payload, { new: true })
|
||||
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({
|
||||
path: 'repostOfPostId quoteOfPostId',
|
||||
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
@@ -81,6 +92,11 @@ export class PostsRepository {
|
||||
.find(this.withActiveFilter(filter))
|
||||
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({
|
||||
path: 'repostOfPostId quoteOfPostId',
|
||||
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
|
||||
})
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
@@ -97,6 +113,7 @@ export class PostsRepository {
|
||||
.find(this.withAdminFilter(filter))
|
||||
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
@@ -113,6 +130,11 @@ export class PostsRepository {
|
||||
.find({ _id: { $in: ids }, isDeleted: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({
|
||||
path: 'repostOfPostId quoteOfPostId',
|
||||
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
|
||||
})
|
||||
.exec();
|
||||
|
||||
const order = new Map(postIds.map((id, idx) => [id, idx]));
|
||||
@@ -198,6 +220,7 @@ export class PostsRepository {
|
||||
)
|
||||
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ import { UsersRepository } from '../users/users.repository';
|
||||
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
|
||||
import { CreateReelDto } from './dto/create-reel.dto';
|
||||
import { CreatePostDto } from './dto/create-post.dto';
|
||||
import { CreateRepostDto } from './dto/create-repost.dto';
|
||||
import { PostQueryDto } from './dto/post-query.dto';
|
||||
import { ReelQueryDto } from './dto/reel-query.dto';
|
||||
import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto';
|
||||
import { UpdatePostDto } from './dto/update-post.dto';
|
||||
import { PostDocument, PostMediaVariantSet } from './schemas/post.schema';
|
||||
import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema';
|
||||
import { PostsRepository } from './posts.repository';
|
||||
|
||||
type PostMediaMetadataInput = Pick<
|
||||
@@ -124,6 +126,8 @@ export class PostsService {
|
||||
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
||||
const finalContent = dto.content?.trim() ?? '';
|
||||
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
|
||||
const collaboratorIds = await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator');
|
||||
const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts);
|
||||
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId);
|
||||
const { location, latitude, longitude } = this.normalizeLocation(dto);
|
||||
if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
|
||||
@@ -143,18 +147,22 @@ export class PostsService {
|
||||
post = await this.postsRepository.create(userId, {
|
||||
content: finalContent,
|
||||
imageUrls: finalImageUrls,
|
||||
imageItems,
|
||||
imageVariants: finalImageVariants,
|
||||
videoUrl: finalVideoUrl,
|
||||
hlsUrl: uploadedHlsUrl,
|
||||
audioUrl: finalAudioUrl,
|
||||
thumbnailVariants: uploadedThumbnailVariants,
|
||||
taggedUserIds,
|
||||
collaboratorIds,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
location,
|
||||
latitude,
|
||||
longitude,
|
||||
postType,
|
||||
visibility: dto.visibility ?? PostVisibility.PUBLIC,
|
||||
commentsDisabled: dto.commentsDisabled ?? false,
|
||||
commentsFollowersOnly: dto.commentsFollowersOnly ?? false,
|
||||
hashtags,
|
||||
...mediaMetadata,
|
||||
});
|
||||
@@ -269,6 +277,16 @@ export class PostsService {
|
||||
typeof dto.taggedUserIds !== 'undefined'
|
||||
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId)
|
||||
: (post.taggedUserIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString()));
|
||||
const nextCollaboratorIds =
|
||||
typeof dto.collaboratorIds !== 'undefined'
|
||||
? await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator')
|
||||
: (post.collaboratorIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString()));
|
||||
const nextImageItems = this.buildImageItems(
|
||||
nextImageUrls,
|
||||
dto.imageCaptions,
|
||||
dto.imageAltTexts,
|
||||
hasImageUpdate ? [] : post.imageItems ?? [],
|
||||
);
|
||||
const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []);
|
||||
const shouldRecomputeMentions =
|
||||
typeof dto.content === 'string' || typeof dto.mentionUsernames !== 'undefined';
|
||||
@@ -309,10 +327,12 @@ export class PostsService {
|
||||
...dto,
|
||||
content: nextContent,
|
||||
imageUrls: nextImageUrls,
|
||||
imageItems: nextImageItems,
|
||||
imageVariants: nextImageVariants,
|
||||
hlsUrl: nextHlsUrl,
|
||||
thumbnailVariants: nextThumbnailVariants,
|
||||
taggedUserIds: nextTaggedUserIds,
|
||||
collaboratorIds: nextCollaboratorIds,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
location: nextLocation,
|
||||
latitude: nextLatitude,
|
||||
@@ -468,7 +488,10 @@ export class PostsService {
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter: Record<string, unknown> = { authorId: new Types.ObjectId(userId) };
|
||||
const filter: Record<string, unknown> = {
|
||||
authorId: new Types.ObjectId(userId),
|
||||
isArchived: { $ne: true },
|
||||
};
|
||||
if (query.visibility) {
|
||||
filter.visibility = query.visibility;
|
||||
}
|
||||
@@ -483,7 +506,7 @@ export class PostsService {
|
||||
}
|
||||
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||
const sortField = query.sortBy ?? 'createdAt';
|
||||
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
|
||||
const sort = { pinnedToProfile: -1, [sortField]: direction } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.postsRepository.findMany(filter, skip, limit, sort),
|
||||
@@ -676,6 +699,69 @@ export class PostsService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateCommentSettings(
|
||||
userId: string,
|
||||
postId: string,
|
||||
dto: UpdateCommentSettingsDto,
|
||||
): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, {
|
||||
...(typeof dto.commentsDisabled === 'boolean' ? { commentsDisabled: dto.commentsDisabled } : {}),
|
||||
...(typeof dto.commentsFollowersOnly === 'boolean'
|
||||
? { commentsFollowersOnly: dto.commentsFollowersOnly }
|
||||
: {}),
|
||||
...(Array.isArray(dto.commentFilterKeywords)
|
||||
? {
|
||||
commentFilterKeywords: Array.from(
|
||||
new Set(dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean)),
|
||||
).slice(0, 50),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async pinToProfile(userId: string, postId: string): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, { pinnedToProfile: true });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async unpinFromProfile(userId: string, postId: string): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, { pinnedToProfile: false });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async archive(userId: string, postId: string): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, { isArchived: true, pinnedToProfile: false });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async restoreArchived(userId: string, postId: string): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, { isArchived: false });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeBySuperAdmin(superAdminIdentifier: string, postId: string): Promise<void> {
|
||||
const post = await this.postsRepository.findById(postId);
|
||||
if (!post) {
|
||||
@@ -929,6 +1015,20 @@ export class PostsService {
|
||||
return { location, latitude, longitude };
|
||||
}
|
||||
|
||||
private buildImageItems(
|
||||
imageUrls: string[],
|
||||
captions: string[] | undefined,
|
||||
altTexts: string[] | undefined,
|
||||
fallback: PostImageItem[] = [],
|
||||
): PostImageItem[] {
|
||||
return imageUrls.map((url, index) => ({
|
||||
url,
|
||||
caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '',
|
||||
altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '',
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
private async normalizeTaggedUserIds(
|
||||
input: string[] | undefined,
|
||||
authorId: string,
|
||||
@@ -969,6 +1069,46 @@ export class PostsService {
|
||||
return unique.map((id) => new Types.ObjectId(id));
|
||||
}
|
||||
|
||||
private async normalizeUserIdList(
|
||||
input: string[] | undefined,
|
||||
currentUserId: string,
|
||||
maxCount: number,
|
||||
label: string,
|
||||
): Promise<Types.ObjectId[]> {
|
||||
if (!input?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
input
|
||||
.map((item) => item?.trim())
|
||||
.filter((item): item is string => !!item)
|
||||
.filter((item) => item !== currentUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length > maxCount) {
|
||||
throw new BadRequestException(`You can add up to ${maxCount} ${label}s only`);
|
||||
}
|
||||
|
||||
if (unique.some((id) => !Types.ObjectId.isValid(id))) {
|
||||
throw new BadRequestException(`Invalid ${label} user id`);
|
||||
}
|
||||
|
||||
const rows = await this.usersRepository.findMany(
|
||||
{ _id: { $in: unique.map((id) => new Types.ObjectId(id)) } },
|
||||
0,
|
||||
unique.length,
|
||||
);
|
||||
|
||||
if (rows.length !== unique.length) {
|
||||
throw new BadRequestException(`One or more ${label}s do not exist`);
|
||||
}
|
||||
|
||||
return unique.map((id) => new Types.ObjectId(id));
|
||||
}
|
||||
|
||||
private async notifyMentionedUsers(
|
||||
actorId: string,
|
||||
postId: string,
|
||||
@@ -1047,6 +1187,37 @@ export class PostsService {
|
||||
}
|
||||
}
|
||||
|
||||
async createRepost(userId: string, sourcePostId: string, dto: CreateRepostDto): Promise<PostDocument> {
|
||||
const sourcePost = await this.postsRepository.findById(sourcePostId);
|
||||
if (!sourcePost) {
|
||||
throw new NotFoundException('Source post not found');
|
||||
}
|
||||
if (this.extractEntityId(sourcePost.authorId) === userId && !dto.content?.trim()) {
|
||||
throw new BadRequestException('You cannot repost your own post without a quote');
|
||||
}
|
||||
|
||||
const content = dto.content?.trim() ?? '';
|
||||
const mentionResolution = await this.resolveMentionTargets(undefined, content, userId);
|
||||
const hashtags = this.extractHashtags(content);
|
||||
const post = await this.postsRepository.create(userId, {
|
||||
content,
|
||||
postType: PostType.TEXT,
|
||||
visibility: dto.visibility ?? PostVisibility.PUBLIC,
|
||||
repostOfPostId: content ? null : new Types.ObjectId(sourcePostId),
|
||||
quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
hashtags,
|
||||
});
|
||||
|
||||
await this.usersRepository.incrementPostsCount(userId, 1);
|
||||
await this.postsRepository.incrementShareCount(sourcePostId, 1);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, content);
|
||||
|
||||
const populated = await this.postsRepository.findById(post.id);
|
||||
return populated ?? post;
|
||||
}
|
||||
|
||||
private async saveMediaFile(
|
||||
mediaType: 'image' | 'video' | 'audio',
|
||||
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
@@ -1343,6 +1514,17 @@ export class PostsService {
|
||||
return generateWaveformPeaksFromSeed(waveformSeed ?? 'audio-post');
|
||||
}
|
||||
|
||||
private async assertPostOwner(userId: string, postId: string): Promise<PostDocument> {
|
||||
const post = await this.postsRepository.findById(postId);
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
if (this.extractEntityId(post.authorId) !== userId) {
|
||||
throw new ForbiddenException('You can update only your own posts');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
private extractEntityId(value: unknown): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
|
||||
@@ -20,6 +20,13 @@ export type PostMediaVariantSet = {
|
||||
highUrl: string;
|
||||
};
|
||||
|
||||
export type PostImageItem = {
|
||||
url: string;
|
||||
caption: string;
|
||||
altText: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
const mediaVariantSetSchema = raw({
|
||||
originalUrl: { type: String, default: '' },
|
||||
lowUrl: { type: String, default: '' },
|
||||
@@ -27,11 +34,24 @@ const mediaVariantSetSchema = raw({
|
||||
highUrl: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const imageItemSchema = raw({
|
||||
url: { type: String, default: '' },
|
||||
caption: { type: String, default: '' },
|
||||
altText: { type: String, default: '' },
|
||||
order: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Post {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
authorId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Post', default: null, index: true })
|
||||
repostOfPostId?: Types.ObjectId | null;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Post', default: null, index: true })
|
||||
quoteOfPostId?: Types.ObjectId | null;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 2200, required: true })
|
||||
content!: string;
|
||||
|
||||
@@ -68,6 +88,9 @@ export class Post {
|
||||
@Prop({ type: [String], default: [] })
|
||||
imageUrls!: string[];
|
||||
|
||||
@Prop({ type: [imageItemSchema], default: [] })
|
||||
imageItems!: PostImageItem[];
|
||||
|
||||
@Prop({ type: [mediaVariantSetSchema], default: [] })
|
||||
imageVariants!: PostMediaVariantSet[];
|
||||
|
||||
@@ -77,6 +100,9 @@ export class Post {
|
||||
@Prop({ type: [String], default: [] })
|
||||
mentionUsernames!: string[];
|
||||
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
|
||||
collaboratorIds!: Types.ObjectId[];
|
||||
|
||||
@Prop({ default: '' })
|
||||
location!: string;
|
||||
|
||||
@@ -110,6 +136,21 @@ export class Post {
|
||||
@Prop({ default: 0, min: 0 })
|
||||
playCount!: number;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
commentsDisabled!: boolean;
|
||||
|
||||
@Prop({ default: false })
|
||||
commentsFollowersOnly!: boolean;
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
commentFilterKeywords!: string[];
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
pinnedToProfile!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isArchived!: boolean;
|
||||
|
||||
@Prop({ type: [String], default: [], index: true })
|
||||
hashtags!: string[];
|
||||
|
||||
@@ -137,10 +178,15 @@ export class Post {
|
||||
export const PostSchema = SchemaFactory.createForClass(Post);
|
||||
|
||||
PostSchema.index({ authorId: 1, createdAt: -1 });
|
||||
PostSchema.index({ repostOfPostId: 1, createdAt: -1 });
|
||||
PostSchema.index({ quoteOfPostId: 1, createdAt: -1 });
|
||||
PostSchema.index({ visibility: 1, createdAt: -1 });
|
||||
PostSchema.index({ postType: 1, createdAt: -1 });
|
||||
PostSchema.index({ hashtags: 1, createdAt: -1 });
|
||||
PostSchema.index({ taggedUserIds: 1, createdAt: -1 });
|
||||
PostSchema.index({ collaboratorIds: 1, createdAt: -1 });
|
||||
PostSchema.index({ authorId: 1, pinnedToProfile: -1, createdAt: -1 });
|
||||
PostSchema.index({ authorId: 1, isArchived: 1, createdAt: -1 });
|
||||
PostSchema.index({ moderationStatus: 1, createdAt: -1 });
|
||||
PostSchema.index({ authorId: 1, isDeleted: 1, createdAt: -1 });
|
||||
PostSchema.index({ visibility: 1, isDeleted: 1, createdAt: -1 });
|
||||
@@ -158,6 +204,12 @@ PostSchema.index({
|
||||
|
||||
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
|
||||
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
|
||||
ret.imageItems = Array.isArray(ret.imageItems)
|
||||
? ret.imageItems.map((item: PostImageItem) => ({
|
||||
...item,
|
||||
url: resolveManagedFileUrl(item.url),
|
||||
}))
|
||||
: [];
|
||||
ret.imageVariants = resolveManagedFileUrlRecords(ret.imageVariants);
|
||||
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
|
||||
ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl);
|
||||
|
||||
29
src/modules/reports/dto/create-report.dto.ts
Normal file
29
src/modules/reports/dto/create-report.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { ReportReason } from '../../../common/enums/report-reason.enum';
|
||||
import { REPORT_TARGET_TYPES, ReportTargetType } from '../schemas/report.schema';
|
||||
|
||||
export class CreateReportDto {
|
||||
@ApiProperty({ enum: REPORT_TARGET_TYPES })
|
||||
@IsEnum(REPORT_TARGET_TYPES)
|
||||
targetType!: ReportTargetType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsMongoId()
|
||||
targetId!: string;
|
||||
|
||||
@ApiProperty({ enum: ReportReason })
|
||||
@IsEnum(ReportReason)
|
||||
reason!: ReportReason;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 2000)
|
||||
details?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'For user reports, block the reported user immediately' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
blockTarget?: boolean;
|
||||
}
|
||||
16
src/modules/reports/dto/report-query.dto.ts
Normal file
16
src/modules/reports/dto/report-query.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { REPORT_STATUSES, REPORT_TARGET_TYPES, ReportStatus, ReportTargetType } from '../schemas/report.schema';
|
||||
|
||||
export class ReportQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: REPORT_TARGET_TYPES })
|
||||
@IsOptional()
|
||||
@IsEnum(REPORT_TARGET_TYPES)
|
||||
targetType?: ReportTargetType;
|
||||
|
||||
@ApiPropertyOptional({ enum: REPORT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(REPORT_STATUSES)
|
||||
status?: ReportStatus;
|
||||
}
|
||||
15
src/modules/reports/dto/update-report-status.dto.ts
Normal file
15
src/modules/reports/dto/update-report-status.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { REPORT_STATUSES, ReportStatus } from '../schemas/report.schema';
|
||||
|
||||
export class UpdateReportStatusDto {
|
||||
@ApiProperty({ enum: REPORT_STATUSES })
|
||||
@IsEnum(REPORT_STATUSES)
|
||||
status!: ReportStatus;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 300 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 300)
|
||||
resolutionNote?: string;
|
||||
}
|
||||
53
src/modules/reports/reports.controller.ts
Normal file
53
src/modules/reports/reports.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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 { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
import { CreateReportDto } from './dto/create-report.dto';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { UpdateReportStatusDto } from './dto/update-report-status.dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateReportDto) {
|
||||
return this.reportsService.create(user.sub, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
async mine(@CurrentUser() user: JwtPayload, @Query() query: ReportQueryDto) {
|
||||
return this.reportsService.listMine(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
||||
@Get('superadmin')
|
||||
async superAdminList(@Query() query: ReportQueryDto) {
|
||||
return this.reportsService.listForSuperAdmin(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
||||
@Patch('superadmin/:reportId/status')
|
||||
async updateStatus(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('reportId') reportId: string,
|
||||
@Body() dto: UpdateReportStatusDto,
|
||||
) {
|
||||
return this.reportsService.updateStatus(user.email ?? user.sub, reportId, dto);
|
||||
}
|
||||
}
|
||||
15
src/modules/reports/reports.module.ts
Normal file
15
src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { BlocksModule } from '../blocks/blocks.module';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { Report, ReportSchema } from './schemas/report.schema';
|
||||
|
||||
@Module({
|
||||
imports: [BlocksModule, MongooseModule.forFeature([{ name: Report.name, schema: ReportSchema }])],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService, ReportsRepository],
|
||||
exports: [ReportsService, ReportsRepository],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
77
src/modules/reports/reports.repository.ts
Normal file
77
src/modules/reports/reports.repository.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Report, ReportDocument, ReportStatus } from './schemas/report.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsRepository {
|
||||
constructor(@InjectModel(Report.name) private readonly reportModel: Model<ReportDocument>) {}
|
||||
|
||||
async create(payload: {
|
||||
reporterId: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
reason: string;
|
||||
details?: string;
|
||||
}): Promise<ReportDocument> {
|
||||
return this.reportModel
|
||||
.findOneAndUpdate(
|
||||
{
|
||||
reporterId: new Types.ObjectId(payload.reporterId),
|
||||
targetType: payload.targetType,
|
||||
targetId: new Types.ObjectId(payload.targetId),
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
reporterId: new Types.ObjectId(payload.reporterId),
|
||||
targetType: payload.targetType,
|
||||
targetId: new Types.ObjectId(payload.targetId),
|
||||
},
|
||||
$set: {
|
||||
reason: payload.reason,
|
||||
details: payload.details ?? '',
|
||||
status: 'open',
|
||||
resolutionNote: '',
|
||||
resolvedBy: '',
|
||||
resolvedAt: null,
|
||||
},
|
||||
},
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findMany(filter: FilterQuery<ReportDocument>, skip: number, limit: number, sort: Record<string, 1 | -1>) {
|
||||
return this.reportModel
|
||||
.find(filter)
|
||||
.populate({ path: 'reporterId', select: 'name username avatar stageName isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<ReportDocument>): Promise<number> {
|
||||
return this.reportModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
reportId: string,
|
||||
status: ReportStatus,
|
||||
resolutionNote: string,
|
||||
resolvedBy: string,
|
||||
): Promise<ReportDocument | null> {
|
||||
return this.reportModel
|
||||
.findByIdAndUpdate(
|
||||
reportId,
|
||||
{
|
||||
status,
|
||||
resolutionNote,
|
||||
resolvedBy,
|
||||
resolvedAt: ['resolved', 'rejected'].includes(status) ? new Date() : null,
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
146
src/modules/reports/reports.service.ts
Normal file
146
src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { BlocksService } from '../blocks/blocks.service';
|
||||
import { CreateReportDto } from './dto/create-report.dto';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { UpdateReportStatusDto } from './dto/update-report-status.dto';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(
|
||||
@InjectConnection() private readonly connection: Connection,
|
||||
private readonly reportsRepository: ReportsRepository,
|
||||
private readonly blocksService: BlocksService,
|
||||
) {}
|
||||
|
||||
async create(reporterId: string, dto: CreateReportDto) {
|
||||
await this.assertTargetExists(dto.targetType, dto.targetId);
|
||||
const report = await this.reportsRepository.create({
|
||||
reporterId,
|
||||
targetType: dto.targetType,
|
||||
targetId: dto.targetId,
|
||||
reason: dto.reason,
|
||||
details: dto.details?.trim() ?? '',
|
||||
});
|
||||
|
||||
const automaticModeration = await this.applyAutomaticModeration(dto.targetType, dto.targetId, dto.reason);
|
||||
const safetyActions = await this.applyReporterSafetyActions(reporterId, dto);
|
||||
return { reported: true, item: report, automaticModeration, safetyActions };
|
||||
}
|
||||
|
||||
async listMine(reporterId: string, query: ReportQueryDto) {
|
||||
return this.list({ ...this.buildFilter(query), reporterId: new Types.ObjectId(reporterId) }, query);
|
||||
}
|
||||
|
||||
async listForSuperAdmin(query: ReportQueryDto) {
|
||||
return this.list(this.buildFilter(query), query);
|
||||
}
|
||||
|
||||
async updateStatus(superAdminIdentifier: string, reportId: string, dto: UpdateReportStatusDto) {
|
||||
if (!Types.ObjectId.isValid(reportId)) {
|
||||
throw new NotFoundException('Report not found');
|
||||
}
|
||||
|
||||
const updated = await this.reportsRepository.updateStatus(
|
||||
reportId,
|
||||
dto.status,
|
||||
dto.resolutionNote?.trim() ?? '',
|
||||
superAdminIdentifier,
|
||||
);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Report not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async list(filter: Record<string, unknown>, query: ReportQueryDto) {
|
||||
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.reportsRepository.findMany(filter, skip, limit, sort),
|
||||
this.reportsRepository.count(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
|
||||
}
|
||||
|
||||
private buildFilter(query: ReportQueryDto): Record<string, unknown> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (query.targetType) {
|
||||
filter.targetType = query.targetType;
|
||||
}
|
||||
if (query.status) {
|
||||
filter.status = query.status;
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
private async assertTargetExists(targetType: string, targetId: string): Promise<void> {
|
||||
if (!Types.ObjectId.isValid(targetId)) {
|
||||
throw new BadRequestException('Invalid target id');
|
||||
}
|
||||
const collectionNameByType: Record<string, string> = {
|
||||
user: 'users',
|
||||
post: 'posts',
|
||||
comment: 'comments',
|
||||
listing: 'instruments',
|
||||
repair_shop: 'repairshops',
|
||||
};
|
||||
const collectionName = collectionNameByType[targetType];
|
||||
const target = collectionName
|
||||
? await this.connection.collection(collectionName).findOne({ _id: new Types.ObjectId(targetId) })
|
||||
: null;
|
||||
if (!target) {
|
||||
throw new NotFoundException('Report target not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async applyAutomaticModeration(targetType: string, targetId: string, reason: string) {
|
||||
if (!['post', 'comment'].includes(targetType)) {
|
||||
return { status: 'not_applicable' };
|
||||
}
|
||||
|
||||
const openReportsCount = await this.connection.collection('reports').countDocuments({
|
||||
targetType,
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
status: { $in: ['open', 'in_review'] },
|
||||
});
|
||||
|
||||
if (openReportsCount < 3) {
|
||||
return { status: 'watching', openReportsCount };
|
||||
}
|
||||
|
||||
const collection = targetType === 'post' ? 'posts' : 'comments';
|
||||
await this.connection.collection(collection).updateOne(
|
||||
{ _id: new Types.ObjectId(targetId) },
|
||||
{
|
||||
$set: {
|
||||
moderationStatus: ModerationStatus.FLAGGED,
|
||||
moderationReason: `Automatically flagged after ${openReportsCount} reports. Latest reason: ${reason}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { status: ModerationStatus.FLAGGED, openReportsCount };
|
||||
}
|
||||
|
||||
private async applyReporterSafetyActions(reporterId: string, dto: CreateReportDto) {
|
||||
if (dto.targetType !== 'user') {
|
||||
return { blockAvailable: false, blocked: false };
|
||||
}
|
||||
|
||||
if (!dto.blockTarget) {
|
||||
return { blockAvailable: true, blocked: false };
|
||||
}
|
||||
|
||||
await this.blocksService.block(reporterId, dto.targetId);
|
||||
return { blockAvailable: true, blocked: true };
|
||||
}
|
||||
}
|
||||
46
src/modules/reports/schemas/report.schema.ts
Normal file
46
src/modules/reports/schemas/report.schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { ReportReason } from '../../../common/enums/report-reason.enum';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type ReportDocument = HydratedDocument<Report>;
|
||||
|
||||
export const REPORT_TARGET_TYPES = ['user', 'post', 'comment', 'listing', 'repair_shop'] as const;
|
||||
export type ReportTargetType = (typeof REPORT_TARGET_TYPES)[number];
|
||||
|
||||
export const REPORT_STATUSES = ['open', 'in_review', 'resolved', 'rejected'] as const;
|
||||
export type ReportStatus = (typeof REPORT_STATUSES)[number];
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Report {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
reporterId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, enum: REPORT_TARGET_TYPES, required: true, index: true })
|
||||
targetType!: ReportTargetType;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
targetId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, enum: Object.values(ReportReason), required: true, index: true })
|
||||
reason!: ReportReason;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 2000 })
|
||||
details!: string;
|
||||
|
||||
@Prop({ type: String, enum: REPORT_STATUSES, default: 'open', index: true })
|
||||
status!: ReportStatus;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 300 })
|
||||
resolutionNote!: string;
|
||||
|
||||
@Prop({ type: String, default: '', index: true })
|
||||
resolvedBy!: string;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
resolvedAt!: Date | null;
|
||||
}
|
||||
|
||||
export const ReportSchema = SchemaFactory.createForClass(Report);
|
||||
ReportSchema.index({ reporterId: 1, targetType: 1, targetId: 1 }, { unique: true });
|
||||
ReportSchema.index({ status: 1, createdAt: -1 });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم