feat: add search and rich collaboration requests
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||
@@ -7,6 +12,7 @@ import { BlocksRepository } from '../blocks/blocks.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto';
|
||||
import {
|
||||
CollaborationRequest,
|
||||
CollaborationRequestDocument,
|
||||
@@ -23,7 +29,8 @@ export class CollaborationRequestsService {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async create(requesterId: string, postId: string, targetUserId: string) {
|
||||
async create(requesterId: string, postId: string, dto: CreateCollaborationRequestDto) {
|
||||
const targetUserId = dto.targetUserId;
|
||||
if (!Types.ObjectId.isValid(postId) || !Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid collaboration request');
|
||||
}
|
||||
@@ -55,21 +62,25 @@ export class CollaborationRequestsService {
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
};
|
||||
const collaborationDetails = this.buildCollaborationDetails(dto);
|
||||
const existing = await this.collaborationRequestModel.findOne(filter).exec();
|
||||
const request = existing ?? await this.collaborationRequestModel
|
||||
.findOneAndUpdate(
|
||||
filter,
|
||||
{
|
||||
$setOnInsert: {
|
||||
postId: new Types.ObjectId(postId),
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true },
|
||||
)
|
||||
.exec();
|
||||
const request = existing
|
||||
? await this.updateExistingPendingRequest(existing.id, collaborationDetails)
|
||||
: await this.collaborationRequestModel
|
||||
.findOneAndUpdate(
|
||||
filter,
|
||||
{
|
||||
$setOnInsert: {
|
||||
postId: new Types.ObjectId(postId),
|
||||
requesterId: new Types.ObjectId(requesterId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
...collaborationDetails,
|
||||
},
|
||||
},
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true },
|
||||
)
|
||||
.exec();
|
||||
|
||||
if (!existing) {
|
||||
await this.notificationsService.create({
|
||||
@@ -79,6 +90,7 @@ export class CollaborationRequestsService {
|
||||
referenceId: postId,
|
||||
resourceType: 'post',
|
||||
deepLink: `/posts/${postId}`,
|
||||
metadata: collaborationDetails,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,7 +105,10 @@ export class CollaborationRequestsService {
|
||||
const [items, total] = await Promise.all([
|
||||
this.collaborationRequestModel
|
||||
.find(filter)
|
||||
.populate({ path: 'requesterId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.populate({
|
||||
path: 'requesterId',
|
||||
select: 'name username stageName avatar isVerified isDisabled',
|
||||
})
|
||||
.populate({ path: 'postId' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
@@ -117,13 +132,43 @@ export class CollaborationRequestsService {
|
||||
return { rejected: true, request };
|
||||
}
|
||||
|
||||
private async updateStatus(targetUserId: string, requestId: string, status: 'approved' | 'rejected') {
|
||||
private buildCollaborationDetails(dto: CreateCollaborationRequestDto) {
|
||||
return {
|
||||
...(dto.collaborationType ? { collaborationType: dto.collaborationType } : {}),
|
||||
...(typeof dto.message === 'string' ? { message: dto.message.trim() } : {}),
|
||||
...(typeof dto.attachmentUrl === 'string' ? { attachmentUrl: dto.attachmentUrl.trim() } : {}),
|
||||
...(dto.attachmentType ? { attachmentType: dto.attachmentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async updateExistingPendingRequest(
|
||||
requestId: string,
|
||||
collaborationDetails: Record<string, unknown>,
|
||||
) {
|
||||
if (!Object.keys(collaborationDetails).length) {
|
||||
return this.collaborationRequestModel.findById(requestId).exec();
|
||||
}
|
||||
|
||||
return this.collaborationRequestModel
|
||||
.findByIdAndUpdate(requestId, { $set: collaborationDetails }, { new: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
private async updateStatus(
|
||||
targetUserId: string,
|
||||
requestId: string,
|
||||
status: 'approved' | 'rejected',
|
||||
) {
|
||||
if (!Types.ObjectId.isValid(requestId)) {
|
||||
throw new BadRequestException('Invalid collaboration request id');
|
||||
}
|
||||
const request = await this.collaborationRequestModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
|
||||
{
|
||||
_id: new Types.ObjectId(requestId),
|
||||
targetUserId: new Types.ObjectId(targetUserId),
|
||||
status: 'pending',
|
||||
},
|
||||
{ status },
|
||||
{ new: true },
|
||||
)
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { IsMongoId } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsMongoId, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
COLLABORATION_ATTACHMENT_TYPES,
|
||||
COLLABORATION_TYPES,
|
||||
CollaborationAttachmentType,
|
||||
CollaborationType,
|
||||
} from '../schemas/collaboration-request.schema';
|
||||
|
||||
export class CreateCollaborationRequestDto {
|
||||
@IsMongoId()
|
||||
targetUserId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@IsEnum(COLLABORATION_TYPES)
|
||||
collaborationType?: CollaborationType;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
message?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
attachmentUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@IsEnum(COLLABORATION_ATTACHMENT_TYPES)
|
||||
attachmentType?: CollaborationAttachmentType;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ export class PostsCollaborationRequestsController {
|
||||
@Param('postId') postId: string,
|
||||
@Body() dto: CreateCollaborationRequestDto,
|
||||
) {
|
||||
return this.collaborationRequestsService.create(user.sub, postId, dto.targetUserId);
|
||||
return this.collaborationRequestsService.create(user.sub, postId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { Post } from '../../posts/schemas/post.schema';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type CollaborationRequestDocument = HydratedDocument<CollaborationRequest>;
|
||||
export const COLLABORATION_TYPES = ['duet', 'arrangement', 'composition'] as const;
|
||||
export type CollaborationType = (typeof COLLABORATION_TYPES)[number];
|
||||
|
||||
export const COLLABORATION_ATTACHMENT_TYPES = ['audio', 'demo', 'file'] as const;
|
||||
export type CollaborationAttachmentType = (typeof COLLABORATION_ATTACHMENT_TYPES)[number];
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class CollaborationRequest {
|
||||
@@ -18,6 +23,18 @@ export class CollaborationRequest {
|
||||
|
||||
@Prop({ enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
|
||||
status!: 'pending' | 'approved' | 'rejected';
|
||||
|
||||
@Prop({ enum: COLLABORATION_TYPES, default: null, index: true })
|
||||
collaborationType?: CollaborationType | null;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 1000 })
|
||||
message!: string;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 500 })
|
||||
attachmentUrl!: string;
|
||||
|
||||
@Prop({ enum: COLLABORATION_ATTACHMENT_TYPES, default: null })
|
||||
attachmentType?: CollaborationAttachmentType | null;
|
||||
}
|
||||
|
||||
export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest);
|
||||
|
||||
@@ -201,6 +201,16 @@ 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 });
|
||||
PostSchema.index(
|
||||
{
|
||||
content: 'text',
|
||||
hashtags: 'text',
|
||||
style: 'text',
|
||||
maqam: 'text',
|
||||
rhythmSignature: 'text',
|
||||
},
|
||||
{ name: 'post_search_text' },
|
||||
);
|
||||
PostSchema.index({
|
||||
visibility: 1,
|
||||
isDeleted: 1,
|
||||
|
||||
57
src/modules/search/dto/search-query.dto.ts
Normal file
57
src/modules/search/dto/search-query.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import { SortOrder } from '../../../common/enums/sort-order.enum';
|
||||
|
||||
export type SearchType = 'all' | 'users' | 'posts' | 'hashtags';
|
||||
|
||||
export class SearchQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search text', minLength: 1, maxLength: 100 })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
q!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['all', 'users', 'posts', 'hashtags'], default: 'all' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@IsIn(['all', 'users', 'posts', 'hashtags'])
|
||||
type?: SearchType = 'all';
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, default: SortOrder.DESC })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@IsIn([SortOrder.ASC, SortOrder.DESC])
|
||||
sortOrder?: SortOrder = SortOrder.DESC;
|
||||
}
|
||||
|
||||
export class SearchSuggestionsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search text', minLength: 1, maxLength: 100 })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
q!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 5, maximum: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
limit?: number = 5;
|
||||
}
|
||||
40
src/modules/search/search.controller.ts
Normal file
40
src/modules/search/search.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { SearchQueryDto, SearchSuggestionsQueryDto } from './dto/search-query.dto';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@ApiTags('Search')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get()
|
||||
async globalSearch(@CurrentUser() user: JwtPayload, @Query() query: SearchQueryDto) {
|
||||
return this.searchService.globalSearch(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async searchUsers(@CurrentUser() user: JwtPayload, @Query() query: SearchQueryDto) {
|
||||
return this.searchService.searchUsers(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('posts')
|
||||
async searchPosts(@CurrentUser() user: JwtPayload, @Query() query: SearchQueryDto) {
|
||||
return this.searchService.searchPosts(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('hashtags')
|
||||
async searchHashtags(@CurrentUser() user: JwtPayload, @Query() query: SearchQueryDto) {
|
||||
return this.searchService.searchHashtags(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('suggestions')
|
||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: SearchSuggestionsQueryDto) {
|
||||
return this.searchService.getSuggestions(user.sub, query);
|
||||
}
|
||||
}
|
||||
20
src/modules/search/search.module.ts
Normal file
20
src/modules/search/search.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { BlocksModule } from '../blocks/blocks.module';
|
||||
import { Post, PostSchema } from '../posts/schemas/post.schema';
|
||||
import { User, UserSchema } from '../users/schemas/user.schema';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BlocksModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: User.name, schema: UserSchema },
|
||||
{ name: Post.name, schema: PostSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
414
src/modules/search/search.service.ts
Normal file
414
src/modules/search/search.service.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
|
||||
import { Connection, FilterQuery, Model, PipelineStage, Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||
import { buildPaginatedResponse, PaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { BlocksRepository } from '../blocks/blocks.repository';
|
||||
import { Post, PostDocument } from '../posts/schemas/post.schema';
|
||||
import { User, UserDocument } from '../users/schemas/user.schema';
|
||||
import { SearchQueryDto, SearchSuggestionsQueryDto, SearchType } from './dto/search-query.dto';
|
||||
|
||||
type SearchUserItem = {
|
||||
_id: string;
|
||||
name: string;
|
||||
stageName: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
isVerified: boolean;
|
||||
isDisabled: boolean;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
};
|
||||
|
||||
type HashtagSearchItem = {
|
||||
tag: string;
|
||||
postsCount: number;
|
||||
};
|
||||
|
||||
const emptyPage = <T>(page: number, limit: number): PaginatedResponse<T> =>
|
||||
buildPaginatedResponse<T>([], { page, limit, total: 0, offset: (page - 1) * limit });
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@InjectConnection() private readonly connection: Connection,
|
||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||
@InjectModel(Post.name) private readonly postModel: Model<PostDocument>,
|
||||
private readonly blocksRepository: BlocksRepository,
|
||||
) {}
|
||||
|
||||
async globalSearch(currentUserId: string, query: SearchQueryDto) {
|
||||
const normalized = this.normalizeSearchText(query.q);
|
||||
const type = query.type ?? 'all';
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
const [users, posts, hashtags] = await Promise.all([
|
||||
type === 'all' || type === 'users'
|
||||
? this.searchUsers(currentUserId, { ...query, q: normalized })
|
||||
: Promise.resolve(emptyPage<SearchUserItem>(page, limit)),
|
||||
type === 'all' || type === 'posts'
|
||||
? this.searchPosts(currentUserId, { ...query, q: normalized })
|
||||
: Promise.resolve(emptyPage<any>(page, limit)),
|
||||
type === 'all' || type === 'hashtags'
|
||||
? this.searchHashtags(currentUserId, { ...query, q: normalized })
|
||||
: Promise.resolve(emptyPage<HashtagSearchItem>(page, limit)),
|
||||
]);
|
||||
|
||||
return {
|
||||
query: normalized,
|
||||
type,
|
||||
users,
|
||||
posts,
|
||||
hashtags,
|
||||
};
|
||||
}
|
||||
|
||||
async searchUsers(
|
||||
currentUserId: string,
|
||||
query: SearchQueryDto,
|
||||
): Promise<PaginatedResponse<SearchUserItem>> {
|
||||
const normalized = this.normalizeSearchText(query.q);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const regex = this.safeRegex(normalized);
|
||||
const excludedIds = await this.getBlockedOrBlockingObjectIds(currentUserId);
|
||||
|
||||
const filter: FilterQuery<UserDocument> = {
|
||||
isDisabled: false,
|
||||
...(excludedIds.length ? { _id: { $nin: excludedIds } } : {}),
|
||||
$or: [{ username: regex }, { name: regex }, { stageName: regex }],
|
||||
};
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.userModel
|
||||
.find(filter)
|
||||
.sort({ isVerified: -1, followersCount: -1, username: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec(),
|
||||
this.userModel.countDocuments(filter).exec(),
|
||||
]);
|
||||
|
||||
const followingSet = await this.getFollowingSet(
|
||||
currentUserId,
|
||||
users.map((user) => user.id),
|
||||
);
|
||||
const items = users.map((user) => this.toUserItem(user, followingSet));
|
||||
|
||||
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
|
||||
}
|
||||
|
||||
async searchPosts(currentUserId: string, query: SearchQueryDto): Promise<PaginatedResponse<any>> {
|
||||
const normalized = this.normalizeSearchText(query.q);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const regex = this.safeRegex(normalized);
|
||||
const [blockedIds, followingIds, activeAuthorIds] = await Promise.all([
|
||||
this.getBlockedOrBlockingObjectIds(currentUserId),
|
||||
this.getFollowingObjectIds(currentUserId),
|
||||
this.getActiveSearchableAuthorIds(currentUserId),
|
||||
]);
|
||||
|
||||
const allowedAuthorIds = activeAuthorIds.filter(
|
||||
(authorId) => !blockedIds.some((blockedId) => blockedId.equals(authorId)),
|
||||
);
|
||||
const currentObjectId = new Types.ObjectId(currentUserId);
|
||||
|
||||
const visibilityClauses: FilterQuery<PostDocument>[] = [
|
||||
{ visibility: PostVisibility.PUBLIC },
|
||||
{ authorId: currentObjectId },
|
||||
];
|
||||
if (followingIds.length) {
|
||||
visibilityClauses.push({
|
||||
visibility: PostVisibility.FOLLOWERS,
|
||||
authorId: { $in: followingIds },
|
||||
});
|
||||
}
|
||||
|
||||
const filter: FilterQuery<PostDocument> = {
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
authorId: { $in: allowedAuthorIds },
|
||||
$and: [
|
||||
{ $or: visibilityClauses },
|
||||
{
|
||||
$or: [
|
||||
{ content: regex },
|
||||
{ hashtags: regex },
|
||||
{ style: regex },
|
||||
{ maqam: regex },
|
||||
{ rhythmSignature: regex },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sortDirection = resolveMongoSortDirection(query.sortOrder);
|
||||
const [posts, total] = await Promise.all([
|
||||
this.postModel
|
||||
.find(filter)
|
||||
.populate({
|
||||
path: 'authorId',
|
||||
select: 'name username avatar isVerified stageName isDisabled',
|
||||
})
|
||||
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
|
||||
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
|
||||
.sort({ createdAt: sortDirection })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec(),
|
||||
this.postModel.countDocuments(filter).exec(),
|
||||
]);
|
||||
|
||||
const postIds = posts.map((post) => post.id);
|
||||
const authorIds = posts
|
||||
.map((post) => this.extractEntityId((post as any).authorId))
|
||||
.filter(Boolean);
|
||||
const [likedSet, savedSet, followingSet] = await Promise.all([
|
||||
this.getLikedPostSet(currentUserId, postIds),
|
||||
this.getSavedPostSet(currentUserId, postIds),
|
||||
this.getFollowingSet(currentUserId, authorIds),
|
||||
]);
|
||||
|
||||
const items = posts.map((post) => {
|
||||
const object = post.toObject();
|
||||
const authorId = this.extractEntityId(object.authorId);
|
||||
|
||||
return {
|
||||
...object,
|
||||
isLiked: likedSet.has(post.id),
|
||||
liked: likedSet.has(post.id),
|
||||
isSaved: savedSet.has(post.id),
|
||||
saved: savedSet.has(post.id),
|
||||
isFollowingAuthor: authorId ? followingSet.has(authorId) : false,
|
||||
};
|
||||
});
|
||||
|
||||
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
|
||||
}
|
||||
|
||||
async searchHashtags(
|
||||
currentUserId: string,
|
||||
query: SearchQueryDto,
|
||||
): Promise<PaginatedResponse<HashtagSearchItem>> {
|
||||
const normalized = this.normalizeHashtag(query.q);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const regex = this.safeRegex(normalized);
|
||||
const [blockedIds, followingIds, activeAuthorIds] = await Promise.all([
|
||||
this.getBlockedOrBlockingObjectIds(currentUserId),
|
||||
this.getFollowingObjectIds(currentUserId),
|
||||
this.getActiveSearchableAuthorIds(currentUserId),
|
||||
]);
|
||||
const currentObjectId = new Types.ObjectId(currentUserId);
|
||||
const allowedAuthorIds = activeAuthorIds.filter(
|
||||
(authorId) => !blockedIds.some((blockedId) => blockedId.equals(authorId)),
|
||||
);
|
||||
const visibilityOr: Record<string, unknown>[] = [
|
||||
{ visibility: PostVisibility.PUBLIC },
|
||||
{ authorId: currentObjectId },
|
||||
];
|
||||
if (followingIds.length) {
|
||||
visibilityOr.push({ visibility: PostVisibility.FOLLOWERS, authorId: { $in: followingIds } });
|
||||
}
|
||||
|
||||
const pipeline: PipelineStage[] = [
|
||||
{
|
||||
$match: {
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
authorId: { $in: allowedAuthorIds },
|
||||
$or: visibilityOr,
|
||||
hashtags: regex,
|
||||
},
|
||||
},
|
||||
{ $unwind: '$hashtags' },
|
||||
{ $match: { hashtags: regex } },
|
||||
{ $group: { _id: '$hashtags', postsCount: { $sum: 1 } } },
|
||||
{ $sort: { postsCount: -1, _id: 1 } },
|
||||
{
|
||||
$facet: {
|
||||
items: [{ $skip: skip }, { $limit: limit }],
|
||||
total: [{ $count: 'count' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [result] = await this.postModel.aggregate(pipeline).exec();
|
||||
const items = ((result?.items ?? []) as Array<{ _id: string; postsCount: number }>).map(
|
||||
(item) => ({
|
||||
tag: item._id,
|
||||
postsCount: item.postsCount,
|
||||
}),
|
||||
);
|
||||
const total = Number(result?.total?.[0]?.count ?? 0);
|
||||
|
||||
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
|
||||
}
|
||||
|
||||
async getSuggestions(currentUserId: string, query: SearchSuggestionsQueryDto) {
|
||||
const normalized = this.normalizeSearchText(query.q);
|
||||
const limit = query.limit ?? 5;
|
||||
const [users, hashtags] = await Promise.all([
|
||||
this.searchUsers(currentUserId, { q: normalized, page: 1, limit, type: 'users' }),
|
||||
this.searchHashtags(currentUserId, { q: normalized, page: 1, limit, type: 'hashtags' }),
|
||||
]);
|
||||
|
||||
return {
|
||||
query: normalized,
|
||||
users: users.items,
|
||||
hashtags: hashtags.items,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSearchText(value: string): string {
|
||||
const normalized = (value ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('q is required');
|
||||
}
|
||||
if (normalized.length > 100) {
|
||||
throw new BadRequestException('q must be 100 characters or less');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeHashtag(value: string): string {
|
||||
return this.normalizeSearchText(value).replace(/^#+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
private safeRegex(value: string): RegExp {
|
||||
return new RegExp(this.escapeRegex(value), 'i');
|
||||
}
|
||||
|
||||
private escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
private async getBlockedOrBlockingObjectIds(currentUserId: string): Promise<Types.ObjectId[]> {
|
||||
const ids = await this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
|
||||
return ids.filter((id) => Types.ObjectId.isValid(id)).map((id) => new Types.ObjectId(id));
|
||||
}
|
||||
|
||||
private async getFollowingObjectIds(currentUserId: string): Promise<Types.ObjectId[]> {
|
||||
const rows = await this.connection
|
||||
.collection('follows')
|
||||
.find({ followerId: new Types.ObjectId(currentUserId) })
|
||||
.project({ followingId: 1 })
|
||||
.toArray();
|
||||
|
||||
return rows
|
||||
.map((row) => row.followingId)
|
||||
.filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
|
||||
}
|
||||
|
||||
private async getFollowingSet(
|
||||
currentUserId: string,
|
||||
targetUserIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const normalized = Array.from(
|
||||
new Set(targetUserIds.filter((id) => Types.ObjectId.isValid(id))),
|
||||
);
|
||||
if (!normalized.length) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await this.connection
|
||||
.collection('follows')
|
||||
.find({
|
||||
followerId: new Types.ObjectId(currentUserId),
|
||||
followingId: { $in: normalized.map((id) => new Types.ObjectId(id)) },
|
||||
})
|
||||
.project({ followingId: 1 })
|
||||
.toArray();
|
||||
|
||||
return new Set(rows.map((row) => row.followingId?.toString()).filter(Boolean));
|
||||
}
|
||||
|
||||
private async getLikedPostSet(currentUserId: string, postIds: string[]): Promise<Set<string>> {
|
||||
return this.getViewerPostStateSet('likes', currentUserId, postIds, 'targetId', {
|
||||
targetType: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
private async getSavedPostSet(currentUserId: string, postIds: string[]): Promise<Set<string>> {
|
||||
return this.getViewerPostStateSet('saves', currentUserId, postIds, 'postId');
|
||||
}
|
||||
|
||||
private async getViewerPostStateSet(
|
||||
collectionName: string,
|
||||
currentUserId: string,
|
||||
postIds: string[],
|
||||
postField: 'targetId' | 'postId',
|
||||
extraFilter: Record<string, unknown> = {},
|
||||
): Promise<Set<string>> {
|
||||
const normalized = Array.from(new Set(postIds.filter((id) => Types.ObjectId.isValid(id))));
|
||||
if (!normalized.length) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const rows = await this.connection
|
||||
.collection(collectionName)
|
||||
.find({
|
||||
userId: new Types.ObjectId(currentUserId),
|
||||
[postField]: { $in: normalized.map((id) => new Types.ObjectId(id)) },
|
||||
...extraFilter,
|
||||
})
|
||||
.project({ [postField]: 1 })
|
||||
.toArray();
|
||||
|
||||
return new Set(rows.map((row) => row[postField]?.toString()).filter(Boolean));
|
||||
}
|
||||
|
||||
private async getActiveSearchableAuthorIds(currentUserId: string): Promise<Types.ObjectId[]> {
|
||||
const rows = await this.userModel.find({ isDisabled: false }).select({ _id: 1 }).lean().exec();
|
||||
const currentObjectId = new Types.ObjectId(currentUserId);
|
||||
const ids = rows
|
||||
.map((row) => row._id)
|
||||
.filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
|
||||
|
||||
if (!ids.some((id) => id.equals(currentObjectId))) {
|
||||
ids.push(currentObjectId);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private toUserItem(user: UserDocument, followingSet: Set<string>): SearchUserItem {
|
||||
const object = user.toObject();
|
||||
return {
|
||||
_id: user.id,
|
||||
name: object.name ?? '',
|
||||
stageName: object.stageName ?? '',
|
||||
username: object.username ?? '',
|
||||
avatar: object.avatar ?? '',
|
||||
isVerified: object.isVerified ?? false,
|
||||
isDisabled: object.isDisabled ?? false,
|
||||
followersCount: object.followersCount ?? 0,
|
||||
followingCount: object.followingCount ?? 0,
|
||||
isFollowing: followingSet.has(user.id),
|
||||
};
|
||||
}
|
||||
|
||||
private extractEntityId(value: unknown): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (value instanceof Types.ObjectId) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'object' && '_id' in value) {
|
||||
const id = (value as { _id?: unknown })._id;
|
||||
return id instanceof Types.ObjectId ? id.toString() : String(id ?? '');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,15 @@ export class User {
|
||||
@Prop({ default: '', trim: true, maxlength: 80, index: true })
|
||||
stageName!: string;
|
||||
|
||||
@Prop({ required: true, trim: true, lowercase: true, unique: true, index: true, minlength: 3, maxlength: 30 })
|
||||
@Prop({
|
||||
required: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
minlength: 3,
|
||||
maxlength: 30,
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@Prop({ required: true, trim: true, lowercase: true, unique: true, index: true })
|
||||
@@ -127,6 +135,11 @@ export class User {
|
||||
export const UserSchema = SchemaFactory.createForClass(User);
|
||||
|
||||
UserSchema.index({ createdAt: -1 });
|
||||
UserSchema.index({ name: 1, isDisabled: 1 });
|
||||
UserSchema.index(
|
||||
{ username: 'text', name: 'text', stageName: 'text' },
|
||||
{ name: 'user_search_text' },
|
||||
);
|
||||
|
||||
const stripLegacyRoleFlags = (_doc: unknown, ret: any) => {
|
||||
delete ret.isInstrumentalist;
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم