diff --git a/src/common/enums/music-role.enum.ts b/src/common/enums/music-role.enum.ts index 08211c2..6d08928 100644 --- a/src/common/enums/music-role.enum.ts +++ b/src/common/enums/music-role.enum.ts @@ -5,7 +5,4 @@ export enum MusicRole { LYRICIST = 'lyricist', PRODUCER = 'producer', ARRANGER = 'arranger', - TEACHER = 'teacher', - STUDENT = 'student', - CONTENT_CREATOR = 'content_creator', } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 5ea392e..800af86 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -62,6 +62,14 @@ export class AuthController { return { message: 'Logged out successfully' }; } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('change-password') + async changePassword(@CurrentUser() user: JwtPayload, @Body() dto: ChangePasswordDto) { + return this.authService.changePassword(user.sub, dto); + } + @HttpCode(HttpStatus.OK) @Post('forgot-password') @Throttle(8, 60_000) @@ -83,14 +91,6 @@ export class AuthController { return this.authService.resetPassword(dto); } - @ApiBearerAuth() - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @Post('change-password') - async changePassword(@CurrentUser() user: JwtPayload, @Body() dto: ChangePasswordDto) { - return this.authService.changePassword(user.sub, dto); - } - @HttpCode(HttpStatus.OK) @Post('send-email-verification') @Throttle(8, 60_000) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 1812529..afbb4df 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -1,69 +1,64 @@ import { UnauthorizedException } from '@nestjs/common'; import { validate } from 'class-validator'; import { hashValue } from '../../common/utils/hash.util'; -import { AuthService } from './auth.service'; import { ChangePasswordDto } from './dto/change-password.dto'; +import { AuthService } from './auth.service'; -const createService = async () => { - const userId = '507f1f77bcf86cd799439011'; - const usersService = { - findByIdWithPassword: jest.fn().mockResolvedValue({ - id: userId, - password: await hashValue('OldPassword123', 4), - isDisabled: false, - }), - updatePassword: jest.fn(), - }; - const authRepository = { - revokeAllUserTokens: jest.fn(), - removeExpiredAndRevoked: jest.fn(), - }; - const configService = { - get: jest.fn((key: string) => (key === 'security.bcryptSaltRounds' ? 4 : undefined)), +describe('AuthService change password', () => { + const createService = (passwordHash: string) => { + const usersService = { + findByIdWithPassword: jest.fn().mockResolvedValue({ id: 'user-1', password: passwordHash }), + updatePassword: jest.fn().mockResolvedValue(undefined), + }; + const authRepository = { + revokeAllUserTokens: jest.fn().mockResolvedValue(undefined), + removeExpiredAndRevoked: jest.fn().mockResolvedValue(undefined), + }; + const configService = { + get: jest.fn((key: string) => (key === 'security.bcryptSaltRounds' ? 8 : undefined)), + }; + const service = new AuthService( + usersService as any, + authRepository as any, + {} as any, + configService as any, + {} as any, + ); + + return { service, usersService, authRepository }; }; - const service = new AuthService( - usersService as any, - authRepository as any, - {} as any, - configService as any, - {} as any, - ); + it('changes password and does not return the hash', async () => { + const passwordHash = await hashValue('OldStrongPass123!', 8); + const { service, usersService, authRepository } = createService(passwordHash); - return { service, userId, usersService, authRepository }; -}; - -describe('AuthService changePassword', () => { - it('changes password, revokes refresh tokens, and returns no password hash', async () => { - const { service, userId, usersService, authRepository } = await createService(); - - const result = await service.changePassword(userId, { - currentPassword: 'OldPassword123', - newPassword: 'NewPassword123', + const result = await service.changePassword('user-1', { + currentPassword: 'OldStrongPass123!', + newPassword: 'NewStrongPass123!', }); - expect(usersService.updatePassword).toHaveBeenCalledWith(userId, expect.any(String)); - expect(authRepository.revokeAllUserTokens).toHaveBeenCalledWith(userId); - expect(authRepository.removeExpiredAndRevoked).toHaveBeenCalledWith(userId); expect(result).toEqual({ message: 'Password changed successfully' }); - expect(result).not.toHaveProperty('password'); + expect(usersService.updatePassword).toHaveBeenCalledWith('user-1', expect.any(String)); + expect(authRepository.revokeAllUserTokens).toHaveBeenCalledWith('user-1'); + expect(JSON.stringify(result)).not.toContain('password'); }); it('rejects an incorrect current password', async () => { - const { service, userId, usersService } = await createService(); + const passwordHash = await hashValue('OldStrongPass123!', 8); + const { service, usersService } = createService(passwordHash); await expect( - service.changePassword(userId, { - currentPassword: 'WrongPassword123', - newPassword: 'NewPassword123', + service.changePassword('user-1', { + currentPassword: 'WrongStrongPass123!', + newPassword: 'NewStrongPass123!', }), ).rejects.toBeInstanceOf(UnauthorizedException); expect(usersService.updatePassword).not.toHaveBeenCalled(); }); - it('validates weak new passwords', async () => { + it('rejects weak new passwords through DTO validation', async () => { const dto = Object.assign(new ChangePasswordDto(), { - currentPassword: 'OldPassword123', + currentPassword: 'OldStrongPass123!', newPassword: 'weakpass', }); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index fad5707..08ac5f1 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -225,6 +225,26 @@ export class AuthService { } } + async changePassword(userId: string, dto: ChangePasswordDto): Promise<{ message: string }> { + const user = await this.usersService.findByIdWithPassword(userId); + if (!user || !user.password) { + throw new UnauthorizedException('Invalid credentials'); + } + + const currentPasswordMatches = await compareHash(dto.currentPassword, user.password); + if (!currentPasswordMatches) { + throw new UnauthorizedException('Current password is incorrect'); + } + + const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true }); + const passwordHash = await hashValue(dto.newPassword, saltRounds); + await this.usersService.updatePassword(userId, passwordHash); + await this.authRepository.revokeAllUserTokens(userId); + await this.authRepository.removeExpiredAndRevoked(userId); + + return { message: 'Password changed successfully' }; + } + async loginWithGoogle(googleUser: { googleId: string; email: string; @@ -540,29 +560,6 @@ export class AuthService { return { message: 'Password reset successfully' }; } - async changePassword(userId: string, dto: ChangePasswordDto): Promise<{ message: string }> { - const user = await this.usersService.findByIdWithPassword(userId); - if (!user || !user.password) { - throw new UnauthorizedException('Invalid credentials'); - } - if (user.isDisabled) { - throw new ForbiddenException('Account is disabled'); - } - - const isMatch = await compareHash(dto.currentPassword, user.password); - if (!isMatch) { - throw new UnauthorizedException('Current password is incorrect'); - } - - const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true }); - const passwordHash = await hashValue(dto.newPassword, saltRounds); - await this.usersService.updatePassword(userId, passwordHash); - await this.authRepository.revokeAllUserTokens(userId); - await this.authRepository.removeExpiredAndRevoked(userId); - - return { message: 'Password changed successfully' }; - } - private async generateAndStoreTokenPair( userId: string, username: string, diff --git a/src/modules/auth/dto/change-password.dto.ts b/src/modules/auth/dto/change-password.dto.ts index 8974984..2704dfa 100644 --- a/src/modules/auth/dto/change-password.dto.ts +++ b/src/modules/auth/dto/change-password.dto.ts @@ -1,18 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, Length, Matches } from 'class-validator'; -const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/; - export class ChangePasswordDto { - @ApiProperty({ minLength: 8, maxLength: 64 }) + @ApiProperty({ minLength: 8, example: 'OldStrongPass123!' }) @IsString() @Length(8, 64) currentPassword!: string; - @ApiProperty({ minLength: 8, maxLength: 64 }) + @ApiProperty({ minLength: 8, example: 'NewStrongPass123!' }) @IsString() @Length(8, 64) - @Matches(PASSWORD_PATTERN, { + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, { message: 'newPassword must contain uppercase, lowercase, and number characters', }) newPassword!: string; diff --git a/src/modules/blocks/blocks.controller.ts b/src/modules/blocks/blocks.controller.ts index 9e2252d..cf51831 100644 --- a/src/modules/blocks/blocks.controller.ts +++ b/src/modules/blocks/blocks.controller.ts @@ -3,8 +3,8 @@ 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'; import { BlockListQueryDto } from './dto/block-list-query.dto'; +import { BlocksService } from './blocks.service'; @ApiTags('Blocks') @ApiBearerAuth() diff --git a/src/modules/blocks/blocks.repository.ts b/src/modules/blocks/blocks.repository.ts index 7f9cdc1..0c83a60 100644 --- a/src/modules/blocks/blocks.repository.ts +++ b/src/modules/blocks/blocks.repository.ts @@ -1,19 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model, PipelineStage, Types } from 'mongoose'; +import { FilterQuery, Model, Types } from 'mongoose'; import { Block, BlockDocument } from './schemas/block.schema'; -export type BlockedUserRow = { - blockedUser: { - _id: Types.ObjectId; - name?: string; - username?: string; - avatar?: string; - stageName?: string; - }; - createdAt?: Date; -}; - @Injectable() export class BlocksRepository { constructor(@InjectModel(Block.name) private readonly blockModel: Model) {} @@ -38,6 +27,51 @@ export class BlocksRepository { .exec(); } + async findBlockedUsers( + blockerId: string, + skip: number, + limit: number, + search?: string, + ): Promise>> { + if (search?.trim()) { + return this.aggregateBlockedUsers(blockerId, skip, limit, search); + } + + return this.blockModel + .find({ blockerId: new Types.ObjectId(blockerId) }) + .populate({ path: 'blockedId', select: 'name username avatar stageName isDisabled' }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .exec(); + } + + async countBlockedUsers(blockerId: string, search?: string): Promise { + const trimmedSearch = search?.trim(); + if (!trimmedSearch) { + return this.blockModel.countDocuments({ blockerId: new Types.ObjectId(blockerId) }).exec(); + } + + const [result] = await this.blockModel + .aggregate<{ total: number }>([ + { $match: { blockerId: new Types.ObjectId(blockerId) } }, + { + $lookup: { + from: 'users', + localField: 'blockedId', + foreignField: '_id', + as: 'blockedUser', + }, + }, + { $unwind: '$blockedUser' }, + { $match: this.buildBlockedUserSearchMatch(trimmedSearch) }, + { $count: 'total' }, + ]) + .exec(); + + return result?.total ?? 0; + } + async create(blockerId: string, blockedId: string): Promise { await this.blockModel .updateOne( @@ -75,46 +109,6 @@ export class BlocksRepository { return rows.map((row) => row.blockedId.toString()); } - async findBlockedUsers( - blockerId: string, - skip: number, - limit: number, - search?: string, - ): Promise { - return this.blockModel - .aggregate([ - { $match: { blockerId: new Types.ObjectId(blockerId) } }, - ...this.blockedUserLookupStages(search), - { $sort: { createdAt: -1 } }, - { $skip: skip }, - { $limit: limit }, - { - $project: { - createdAt: 1, - blockedUser: { - _id: '$blockedUser._id', - name: '$blockedUser.name', - username: '$blockedUser.username', - avatar: '$blockedUser.avatar', - stageName: '$blockedUser.stageName', - }, - }, - }, - ]) - .exec(); - } - - async countBlockedUsers(blockerId: string, search?: string): Promise { - const rows = await this.blockModel - .aggregate<{ total: number }>([ - { $match: { blockerId: new Types.ObjectId(blockerId) } }, - ...this.blockedUserLookupStages(search), - { $count: 'total' }, - ]) - .exec(); - return rows[0]?.total ?? 0; - } - async findBlockingOrBlockedIds(userId: string): Promise { const userObjectId = new Types.ObjectId(userId); const rows = await this.blockModel @@ -132,38 +126,52 @@ export class BlocksRepository { ); } - private blockedUserLookupStages(search?: string): PipelineStage[] { - const stages: PipelineStage[] = [ - { - $lookup: { - from: 'users', - localField: 'blockedId', - foreignField: '_id', - as: 'blockedUser', + private async aggregateBlockedUsers( + blockerId: string, + skip: number, + limit: number, + search: string, + ): Promise[]> { + const trimmedSearch = search.trim(); + return this.blockModel + .aggregate>([ + { $match: { blockerId: new Types.ObjectId(blockerId) } }, + { + $lookup: { + from: 'users', + localField: 'blockedId', + foreignField: '_id', + as: 'blockedUser', + }, }, - }, - { $unwind: '$blockedUser' }, - { $match: { 'blockedUser.isDisabled': { $ne: true } } }, - ]; - const searchFilter = this.buildBlockedUserSearch(search); - if (searchFilter) { - stages.push({ $match: searchFilter }); - } - return stages; + { $unwind: '$blockedUser' }, + { $match: this.buildBlockedUserSearchMatch(trimmedSearch) }, + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $project: { + blockedId: { + _id: '$blockedUser._id', + name: '$blockedUser.name', + username: '$blockedUser.username', + avatar: '$blockedUser.avatar', + stageName: '$blockedUser.stageName', + isDisabled: '$blockedUser.isDisabled', + }, + createdAt: 1, + }, + }, + ]) + .exec(); } - private buildBlockedUserSearch(search?: string): PipelineStage.Match['$match'] | undefined { - const q = search?.trim(); - if (!q) { - return undefined; - } - - const regex = { $regex: q, $options: 'i' }; + private buildBlockedUserSearchMatch(search: string): FilterQuery { return { $or: [ - { 'blockedUser.name': regex }, - { 'blockedUser.username': regex }, - { 'blockedUser.stageName': regex }, + { 'blockedUser.name': { $regex: search, $options: 'i' } }, + { 'blockedUser.username': { $regex: search, $options: 'i' } }, + { 'blockedUser.stageName': { $regex: search, $options: 'i' } }, ], }; } diff --git a/src/modules/blocks/blocks.service.spec.ts b/src/modules/blocks/blocks.service.spec.ts index 47f845b..3b78994 100644 --- a/src/modules/blocks/blocks.service.spec.ts +++ b/src/modules/blocks/blocks.service.spec.ts @@ -1,51 +1,53 @@ import { Types } from 'mongoose'; import { BlocksService } from './blocks.service'; -describe('BlocksService listBlockedUsers', () => { - it('returns blocked users with pagination metadata', async () => { - const currentUserId = new Types.ObjectId().toString(); - const blockedUserId = new Types.ObjectId(); - const blockedAt = new Date('2026-06-01T00:00:00.000Z'); +describe('BlocksService list', () => { + it('returns paginated blocked users without sensitive fields', async () => { + const blockedId = new Types.ObjectId(); + const createdAt = new Date('2026-06-01T10:00:00.000Z'); const blocksRepository = { findBlockedUsers: jest.fn().mockResolvedValue([ { - blockedUser: { - _id: blockedUserId, - name: 'Blocked User', - username: 'blocked_user', - avatar: '', + blockedId: { + _id: blockedId, + name: 'Artist', + username: 'artist', + avatar: '/uploads/avatar.jpg', stageName: 'Stage', + email: 'hidden@example.com', }, - createdAt: blockedAt, + createdAt, }, ]), countBlockedUsers: jest.fn().mockResolvedValue(1), }; - const service = new BlocksService(blocksRepository as any, {} as any); - await expect(service.listBlockedUsers(currentUserId, { page: 1, limit: 20 })).resolves.toEqual({ - items: [ - { - userId: blockedUserId.toString(), - name: 'Blocked User', - username: 'blocked_user', - avatar: '', - stageName: 'Stage', - blockedAt, - }, - ], - pagination: { page: 1, limit: 20, total: 1, pages: 1 }, + const result = await service.listBlockedUsers(new Types.ObjectId().toString(), { + page: 1, + limit: 20, }); + + expect(result.items).toEqual([ + { + userId: blockedId.toString(), + name: 'Artist', + username: 'artist', + avatar: expect.any(String), + stageName: 'Stage', + blockedAt: createdAt.toISOString(), + }, + ]); + expect(JSON.stringify(result.items)).not.toContain('hidden@example.com'); + expect(result.pagination).toMatchObject({ page: 1, limit: 20, total: 1, totalPages: 1 }); }); - it('removes a user from the block list through unblock', async () => { + it('unblock removes the user from future list results through repository deletion', async () => { const currentUserId = new Types.ObjectId().toString(); const targetUserId = new Types.ObjectId().toString(); const blocksRepository = { - remove: jest.fn(), + remove: jest.fn().mockResolvedValue(undefined), }; - const service = new BlocksService(blocksRepository as any, {} as any); await expect(service.unblock(currentUserId, targetUserId)).resolves.toEqual({ diff --git a/src/modules/blocks/blocks.service.ts b/src/modules/blocks/blocks.service.ts index 35fc544..8ba4c49 100644 --- a/src/modules/blocks/blocks.service.ts +++ b/src/modules/blocks/blocks.service.ts @@ -1,9 +1,10 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Types } from 'mongoose'; +import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { UsersRepository } from '../users/users.repository'; -import { BlocksRepository } from './blocks.repository'; import { BlockListQueryDto } from './dto/block-list-query.dto'; +import { BlocksRepository } from './blocks.repository'; @Injectable() export class BlocksService { @@ -29,36 +30,6 @@ export class BlocksService { return { blocked: false, targetUserId }; } - async listBlockedUsers(currentUserId: string, query: BlockListQueryDto) { - const page = query.page ?? 1; - const limit = query.limit ?? 20; - const skip = (page - 1) * limit; - const [rows, total] = await Promise.all([ - this.blocksRepository.findBlockedUsers(currentUserId, skip, limit, query.search), - this.blocksRepository.countBlockedUsers(currentUserId, query.search), - ]); - - return { - items: rows.map((row) => { - const blockedUser = row.blockedUser; - return { - userId: blockedUser._id?.toString?.() ?? '', - name: blockedUser.name ?? '', - username: blockedUser.username ?? '', - avatar: resolveManagedFileUrl(blockedUser.avatar ?? ''), - stageName: blockedUser.stageName ?? '', - blockedAt: (row as unknown as { createdAt?: Date }).createdAt ?? null, - }; - }), - pagination: { - page, - limit, - total, - pages: Math.ceil(total / limit), - }, - }; - } - async getStatus(currentUserId: string, targetUserId: string) { this.assertValidTarget(currentUserId, targetUserId); const [iBlocked, blockedMe] = await Promise.all([ @@ -69,6 +40,26 @@ export class BlocksService { return { targetUserId, iBlocked: !!iBlocked, blockedMe: !!blockedMe }; } + async listBlockedUsers(currentUserId: string, query: BlockListQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const [rows, total] = await Promise.all([ + this.blocksRepository.findBlockedUsers(currentUserId, skip, limit, query.search), + this.blocksRepository.countBlockedUsers(currentUserId, query.search), + ]); + + const items = rows.map((row) => this.serializeBlockedUser(row)); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + } + async getBlockedIds(currentUserId: string): Promise { return this.blocksRepository.findBlockedIds(currentUserId); } @@ -77,16 +68,6 @@ export class BlocksService { return this.blocksRepository.findBlockingOrBlockedIds(currentUserId); } - async hasBlockBetween(currentUserId: string, targetUserId: string): Promise { - if (!Types.ObjectId.isValid(currentUserId) || !Types.ObjectId.isValid(targetUserId)) { - return false; - } - if (currentUserId === targetUserId) { - return false; - } - return !!(await this.blocksRepository.findAnyBetween(currentUserId, targetUserId)); - } - async assertNoBlockBetween(currentUserId: string, targetUserId: string): Promise { this.assertValidTarget(currentUserId, targetUserId); const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId); @@ -103,4 +84,17 @@ export class BlocksService { throw new BadRequestException('You cannot block yourself'); } } + + private serializeBlockedUser(row: any) { + const blockedUser = row.blockedId ?? {}; + const id = blockedUser._id?.toString?.() ?? blockedUser.id?.toString?.() ?? ''; + return { + userId: id, + name: blockedUser.name ?? '', + username: blockedUser.username ?? '', + avatar: resolveManagedFileUrl(blockedUser.avatar ?? ''), + stageName: blockedUser.stageName ?? '', + blockedAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : (row.createdAt ?? null), + }; + } } diff --git a/src/modules/blocks/dto/block-list-query.dto.ts b/src/modules/blocks/dto/block-list-query.dto.ts index 836ea7d..69d328f 100644 --- a/src/modules/blocks/dto/block-list-query.dto.ts +++ b/src/modules/blocks/dto/block-list-query.dto.ts @@ -3,7 +3,7 @@ import { IsOptional, IsString } from 'class-validator'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; export class BlockListQueryDto extends PaginationQueryDto { - @ApiPropertyOptional({ description: 'Search blocked users by name, username, or stage name' }) + @ApiPropertyOptional({ description: 'Search by blocked user name, username, or stage name' }) @IsOptional() @IsString() search?: string; diff --git a/src/modules/chat/chat.service.spec.ts b/src/modules/chat/chat.service.spec.ts index 68e7220..4ae3b1f 100644 --- a/src/modules/chat/chat.service.spec.ts +++ b/src/modules/chat/chat.service.spec.ts @@ -68,6 +68,13 @@ describe('ChatService realtime message broadcasting', () => { expect(result).toBe(message); expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1); expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledWith(conversationId, message); + expect(notificationsService.createMessageNotification).toHaveBeenCalledWith( + senderId, + recipientId, + conversationId, + 'hello', + message.id, + ); }); it('emits new_message after a media upload message is created through the service', async () => { diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts index b83f17c..13db068 100644 --- a/src/modules/chat/chat.service.ts +++ b/src/modules/chat/chat.service.ts @@ -205,6 +205,7 @@ export class ChatService { conversation.participantIds.map((id) => id.toString()), conversation.id, preview, + message.id, ); return message; @@ -480,6 +481,7 @@ export class ChatService { participantIds: string[], conversationId: string, previewText: string, + messageId: string, ): Promise { for (const recipientId of participantIds) { if (recipientId === actorId) { @@ -492,6 +494,7 @@ export class ChatService { recipientId, conversationId, previewText.slice(0, 160), + messageId, ); } catch (error) { this.logger.warn( diff --git a/src/modules/comments/comments.service.spec.ts b/src/modules/comments/comments.service.spec.ts index e5a292c..eb4209e 100644 --- a/src/modules/comments/comments.service.spec.ts +++ b/src/modules/comments/comments.service.spec.ts @@ -44,8 +44,8 @@ describe('CommentsService', () => { const followsRepository = { findOne: jest.fn(), }; - const blocksService = { - hasBlockBetween: jest.fn().mockResolvedValue(false), + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), }; const service = new CommentsService( @@ -56,7 +56,7 @@ describe('CommentsService', () => { notificationsService as any, usersRepository as any, followsRepository as any, - blocksService as any, + blocksRepository as any, ); const result = await service.update(userId, commentId, { @@ -87,24 +87,23 @@ describe('CommentsService', () => { ); }); - it('blocks comments when the commenter and post author have a block relation', async () => { + it('prevents creating a comment when either user blocked the post owner', async () => { const userId = new Types.ObjectId().toString(); - const authorId = new Types.ObjectId().toString(); + const ownerId = new Types.ObjectId().toString(); const postId = new Types.ObjectId().toString(); const commentsRepository = { create: jest.fn(), }; const postsRepository = { findById: jest.fn().mockResolvedValue({ - id: postId, - authorId: new Types.ObjectId(authorId), + _id: new Types.ObjectId(postId), + authorId: new Types.ObjectId(ownerId), commentsDisabled: false, commentsFollowersOnly: false, }), - setCommentsCount: jest.fn(), }; - const blocksService = { - hasBlockBetween: jest.fn().mockResolvedValue(true), + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }), }; const service = new CommentsService( @@ -115,12 +114,12 @@ describe('CommentsService', () => { { createCommentNotification: jest.fn(), createMentionNotification: jest.fn() } as any, { findByUsernames: jest.fn() } as any, { findOne: jest.fn() } as any, - blocksService as any, + blocksRepository as any, ); - await expect( - service.create(userId, { postId, content: 'Blocked comment' }), - ).rejects.toThrow('Post not found'); + await expect(service.create(userId, { postId, content: 'hello' })).rejects.toThrow( + 'You cannot interact with this user', + ); expect(commentsRepository.create).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/comments/comments.service.ts b/src/modules/comments/comments.service.ts index 4c5bc8d..dd4dc0f 100644 --- a/src/modules/comments/comments.service.ts +++ b/src/modules/comments/comments.service.ts @@ -6,10 +6,10 @@ 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 { BlocksService } from '../blocks/blocks.service'; import { FollowsRepository } from '../follows/follows.repository'; import { PostsRepository } from '../posts/posts.repository'; import { UsersRepository } from '../users/users.repository'; +import { BlocksRepository } from '../blocks/blocks.repository'; import { AdminCommentQueryDto } from './dto/admin-comment-query.dto'; import { CommentQueryDto, CommentSortBy } from './dto/comment-query.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; @@ -49,7 +49,7 @@ export class CommentsService { private readonly notificationsService: NotificationsService, private readonly usersRepository: UsersRepository, private readonly followsRepository: FollowsRepository, - private readonly blocksService: BlocksService, + private readonly blocksRepository: BlocksRepository, ) {} async create(userId: string, dto: CreateCommentDto) { @@ -65,8 +65,8 @@ export class CommentsService { if (!parent || parent.postId.toString() !== dto.postId) { throw new NotFoundException('Parent comment not found'); } - await this.assertNoBlockBetween(userId, parent.authorId.toString()); parentRecipientId = parent.authorId.toString(); + await this.assertNoBlockBetween(userId, parentRecipientId); } const content = dto.content.trim(); @@ -393,28 +393,17 @@ export class CommentsService { } const authorId = this.extractEntityId(post.authorId); + await this.assertNoBlockBetween(userId, authorId); if (!post.commentsFollowersOnly || authorId === userId) { - await this.assertNoBlockBetween(userId, authorId); return; } - await this.assertNoBlockBetween(userId, authorId); const followsAuthor = await this.followsRepository.findOne(userId, authorId); if (!followsAuthor) { throw new ForbiddenException('Only followers can comment on this post'); } } - private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise { - if (!targetUserId || actorId === targetUserId) { - return; - } - const blocked = await this.blocksService.hasBlockBetween(actorId, targetUserId); - if (blocked) { - throw new NotFoundException('Post not found'); - } - } - private matchesCommentFilter(content: string, keywords: string[] = []): boolean { const normalized = content.toLowerCase(); return keywords @@ -561,9 +550,6 @@ export class CommentsService { } for (const recipientId of recipients) { - if (await this.blocksService.hasBlockBetween(actorId, recipientId)) { - continue; - } try { await this.notificationsService.createCommentNotification(actorId, recipientId, postId, { resourceType: 'post', @@ -650,9 +636,6 @@ export class CommentsService { if (excludedRecipientIds.has(mentionedUser.id)) { continue; } - if (await this.blocksService.hasBlockBetween(actorId, mentionedUser.id)) { - continue; - } try { await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, { @@ -670,6 +653,16 @@ export class CommentsService { } } + private async assertNoBlockBetween(userId: string, targetUserId: string): Promise { + if (!targetUserId || userId === targetUserId) { + return; + } + const block = await this.blocksRepository.findAnyBetween(userId, targetUserId); + if (block) { + throw new ForbiddenException('You cannot interact with this user'); + } + } + private extractEntityId(value: unknown): string { if (!value) { return ''; diff --git a/src/modules/follows/follows.service.spec.ts b/src/modules/follows/follows.service.spec.ts index cb74d2e..4b919e4 100644 --- a/src/modules/follows/follows.service.spec.ts +++ b/src/modules/follows/follows.service.spec.ts @@ -91,28 +91,4 @@ describe('FollowsService', () => { expect(followsRepository.create).not.toHaveBeenCalled(); expect(outboxService.enqueueFollowNotification).not.toHaveBeenCalled(); }); - - it('blocks follow when either user has blocked the other', async () => { - const currentUserId = '507f1f77bcf86cd799439011'; - const targetUserId = '507f191e810c19729de860ea'; - const followsRepository = { - findOne: jest.fn(), - create: jest.fn(), - }; - const usersRepository = { - findById: jest.fn().mockResolvedValue({ id: targetUserId, isDisabled: false, isPrivate: false }), - }; - const service = new FollowsService( - followsRepository as any, - usersRepository as any, - { enqueueFollowNotification: jest.fn() } as any, - { bumpGlobalVersion: jest.fn() } as any, - { findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }) } as any, - ); - - await expect(service.followUser(currentUserId, targetUserId)).rejects.toThrow( - 'You cannot follow this user', - ); - expect(followsRepository.create).not.toHaveBeenCalled(); - }); }); diff --git a/src/modules/likes/likes.module.ts b/src/modules/likes/likes.module.ts index feccdd1..4ddfd0c 100644 --- a/src/modules/likes/likes.module.ts +++ b/src/modules/likes/likes.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { CommentsModule } from '../comments/comments.module'; -import { BlocksModule } from '../blocks/blocks.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PostsModule } from '../posts/posts.module'; +import { BlocksModule } from '../blocks/blocks.module'; import { Like, LikeSchema } from './schemas/like.schema'; import { LikesController } from './likes.controller'; import { LikesRepository } from './likes.repository'; diff --git a/src/modules/likes/likes.service.spec.ts b/src/modules/likes/likes.service.spec.ts index 915d0b1..79f4aeb 100644 --- a/src/modules/likes/likes.service.spec.ts +++ b/src/modules/likes/likes.service.spec.ts @@ -11,6 +11,9 @@ describe('LikesService', () => { const commentsRepository = { findById: jest.fn(), }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + }; const service = new LikesService( likesRepository as any, @@ -18,7 +21,7 @@ describe('LikesService', () => { commentsRepository as any, { bumpGlobalVersion: jest.fn() } as any, { createLikeNotification: jest.fn() } as any, - { hasBlockBetween: jest.fn().mockResolvedValue(false) } as any, + blocksRepository as any, ); await expect( @@ -32,34 +35,35 @@ describe('LikesService', () => { expect(likesRepository.findOne).not.toHaveBeenCalled(); }); - it('blocks likes when the actor and content owner have a block relation', async () => { + it('prevents liking a post when either user blocked the other', async () => { + const ownerId = '507f191e810c19729de860ea'; + const postId = '507f1f77bcf86cd799439011'; + const userId = '507f1f77bcf86cd799439012'; const likesRepository = { findOne: jest.fn(), - create: jest.fn(), }; const postsRepository = { - findById: jest.fn().mockResolvedValue({ - id: '507f1f77bcf86cd799439012', - authorId: '507f1f77bcf86cd799439013', - content: 'Post', - }), - incrementLikesCount: jest.fn(), + findById: jest.fn().mockResolvedValue({ authorId: ownerId }), }; + const commentsRepository = { + findById: jest.fn(), + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }), + }; + const service = new LikesService( likesRepository as any, postsRepository as any, - { findById: jest.fn() } as any, + commentsRepository as any, { bumpGlobalVersion: jest.fn() } as any, { createLikeNotification: jest.fn() } as any, - { hasBlockBetween: jest.fn().mockResolvedValue(true) } as any, + blocksRepository as any, ); - await expect( - service.like('507f1f77bcf86cd799439011', { - targetId: '507f1f77bcf86cd799439012', - targetType: 'post', - }), - ).rejects.toThrow('Target not found'); - expect(likesRepository.create).not.toHaveBeenCalled(); + await expect(service.like(userId, { targetId: postId, targetType: 'post' })).rejects.toThrow( + 'You cannot interact with this user', + ); + expect(likesRepository.findOne).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/likes/likes.service.ts b/src/modules/likes/likes.service.ts index afa0d3d..fc4fa2c 100644 --- a/src/modules/likes/likes.service.ts +++ b/src/modules/likes/likes.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, 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 { BlocksService } from '../blocks/blocks.service'; +import { BlocksRepository } from '../blocks/blocks.repository'; import { CommentsRepository } from '../comments/comments.repository'; import { PostsRepository } from '../posts/posts.repository'; import { LikesRepository } from './likes.repository'; @@ -19,7 +19,7 @@ export class LikesService { private readonly commentsRepository: CommentsRepository, private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, - private readonly blocksService: BlocksService, + private readonly blocksRepository: BlocksRepository, ) {} async toggle(userId: string, dto: ToggleLikeDto) { @@ -29,8 +29,8 @@ export class LikesService { async like(userId: string, dto: ToggleLikeDto) { await this.assertTargetExists(dto); + await this.assertCanLike(userId, dto); const notificationContext = await this.resolveNotificationContext(dto); - await this.assertNoBlockBetween(userId, notificationContext.recipientId); const reactionType = dto.reactionType ?? ReactionType.LIKE; const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType); @@ -52,11 +52,7 @@ export class LikesService { await this.postsRepository.incrementLikesCount(dto.targetId, 1); } await this.feedVersionService.bumpGlobalVersion(); - if ( - notificationContext.recipientId && - notificationContext.recipientId !== userId && - !(await this.blocksService.hasBlockBetween(userId, notificationContext.recipientId)) - ) { + if (notificationContext.recipientId && notificationContext.recipientId !== userId) { try { await this.notificationsService.createLikeNotification( userId, @@ -123,6 +119,18 @@ export class LikesService { } } + private async assertCanLike(userId: string, dto: ToggleLikeDto): Promise { + const ownerId = await this.resolveTargetOwnerId(dto); + if (!ownerId || ownerId === userId) { + return; + } + + const block = await this.blocksRepository.findAnyBetween(userId, ownerId); + if (block) { + throw new ForbiddenException('You cannot interact with this user'); + } + } + private async targetExists(dto: ToggleLikeDto): Promise { if (dto.targetType === 'post') { const post = await this.postsRepository.findById(dto.targetId); @@ -151,13 +159,14 @@ export class LikesService { }; } - private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise { - if (!targetUserId || actorId === targetUserId) { - return; - } - if (await this.blocksService.hasBlockBetween(actorId, targetUserId)) { - throw new NotFoundException('Target not found'); + private async resolveTargetOwnerId(dto: ToggleLikeDto): Promise { + if (dto.targetType === 'post') { + const post = await this.postsRepository.findById(dto.targetId); + return this.extractEntityId(post?.authorId); } + + const comment = await this.commentsRepository.findById(dto.targetId); + return comment?.authorId?.toString?.() ?? ''; } private extractEntityId(value: unknown): string { diff --git a/src/modules/metadata/metadata.module.ts b/src/modules/metadata/metadata.module.ts index 84cfc87..fd381dc 100644 --- a/src/modules/metadata/metadata.module.ts +++ b/src/modules/metadata/metadata.module.ts @@ -5,6 +5,5 @@ import { MetadataService } from './metadata.service'; @Module({ controllers: [MetadataController], providers: [MetadataService], - exports: [MetadataService], }) export class MetadataModule {} diff --git a/src/modules/metadata/metadata.service.spec.ts b/src/modules/metadata/metadata.service.spec.ts index 6a45124..614d9e7 100644 --- a/src/modules/metadata/metadata.service.spec.ts +++ b/src/modules/metadata/metadata.service.spec.ts @@ -1,17 +1,15 @@ import { MetadataService } from './metadata.service'; describe('MetadataService', () => { - it('returns profile dropdown options', () => { + it('returns public profile option lists for Flutter', () => { const service = new MetadataService(); - expect(service.getProfileOptions()).toEqual( - expect.objectContaining({ - musicRoles: expect.arrayContaining(['instrumentalist', 'teacher']), - maqams: expect.arrayContaining(['Hijaz', 'Rast']), - instruments: expect.arrayContaining(['Oud', 'Piano']), - experienceLevels: expect.arrayContaining(['beginner', 'professional']), - moods: expect.arrayContaining(['Tarab', 'Classical']), - }), + const result = service.getProfileOptions(); + + expect(Object.keys(result).sort()).toEqual( + ['experienceLevels', 'instruments', 'maqams', 'moods', 'musicRoles'].sort(), ); + expect(result.musicRoles.length).toBeGreaterThan(0); + expect(Object.values(result).flat().every((value) => typeof value === 'string')).toBe(true); }); }); diff --git a/src/modules/metadata/metadata.service.ts b/src/modules/metadata/metadata.service.ts index 2cff57a..7b0febc 100644 --- a/src/modules/metadata/metadata.service.ts +++ b/src/modules/metadata/metadata.service.ts @@ -4,6 +4,12 @@ import { PROFILE_OPTIONS, ProfileOptionsResponse } from './profile-options.const @Injectable() export class MetadataService { getProfileOptions(): ProfileOptionsResponse { - return PROFILE_OPTIONS; + return { + musicRoles: [...PROFILE_OPTIONS.musicRoles], + maqams: [...PROFILE_OPTIONS.maqams], + instruments: [...PROFILE_OPTIONS.instruments], + experienceLevels: [...PROFILE_OPTIONS.experienceLevels], + moods: [...PROFILE_OPTIONS.moods], + }; } } diff --git a/src/modules/metadata/profile-options.constants.ts b/src/modules/metadata/profile-options.constants.ts index b86a6b5..6eb1f45 100644 --- a/src/modules/metadata/profile-options.constants.ts +++ b/src/modules/metadata/profile-options.constants.ts @@ -1,22 +1,11 @@ -import { ExperienceLevel } from '../../common/enums/experience-level.enum'; -import { MusicRole } from '../../common/enums/music-role.enum'; - export const PROFILE_OPTIONS = { - musicRoles: [ - MusicRole.INSTRUMENTALIST, - MusicRole.SINGER, - MusicRole.COMPOSER, - MusicRole.LYRICIST, - MusicRole.PRODUCER, - MusicRole.ARRANGER, - MusicRole.TEACHER, - MusicRole.STUDENT, - MusicRole.CONTENT_CREATOR, - ], - maqams: ['Hijaz', 'Bayati', 'Rast', 'Kurd', 'Saba', 'Nahawand', 'Ajam'], - instruments: ['Oud', 'Qanun', 'Nay', 'Violin', 'Piano', 'Guitar', 'Percussion'], - experienceLevels: Object.values(ExperienceLevel), - moods: ['Tarab', 'Eastern', 'Calm', 'Sad', 'Energetic', 'Classical'], + musicRoles: ['عازف', 'مغني', 'ملحن', 'مدرس', 'طالب', 'صانع محتوى'], + maqams: ['حجاز', 'بيات', 'راست', 'كرد', 'صبا', 'نهاوند', 'عجم'], + instruments: ['العود', 'القانون', 'الناي', 'الكمان', 'البيانو', 'الجيتار', 'الإيقاع'], + experienceLevels: ['مبتدئ', 'متوسط', 'متقدم', 'محترف'], + moods: ['طربي', 'شرقي', 'هادئ', 'حزين', 'حماسي', 'كلاسيكي'], } as const; -export type ProfileOptionsResponse = typeof PROFILE_OPTIONS; +export type ProfileOptionsResponse = { + [Key in keyof typeof PROFILE_OPTIONS]: string[]; +}; diff --git a/src/modules/notifications/notifications.service.spec.ts b/src/modules/notifications/notifications.service.spec.ts index 0475bbe..c7504b7 100644 --- a/src/modules/notifications/notifications.service.spec.ts +++ b/src/modules/notifications/notifications.service.spec.ts @@ -1,6 +1,7 @@ import { NotFoundException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { Types } from 'mongoose'; import { NotificationUnreadCountQueryDto } from './dto/notification-query.dto'; import { NotificationsService } from './notifications.service'; @@ -44,6 +45,45 @@ describe('NotificationsService', () => { ); }); + it('creates message notifications with Flutter navigation metadata', async () => { + const notificationsRepository = { + create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }), + countUnread: jest.fn().mockResolvedValue(1), + countUnreadByFilter: jest.fn().mockResolvedValue(0), + }; + const notificationsGateway = { + emitCreated: jest.fn(), + }; + + const service = new NotificationsService( + notificationsRepository as any, + notificationsGateway as any, + ); + + await service.createMessageNotification( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + '507f1f77bcf86cd799439012', + 'hello', + '507f1f77bcf86cd799439013', + ); + + expect(notificationsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'message', + referenceId: expect.any(Types.ObjectId), + resourceType: 'conversation', + deepLink: '/chat/conversations/507f1f77bcf86cd799439012', + previewText: 'hello', + metadata: { + type: 'message', + conversationId: '507f1f77bcf86cd799439012', + messageId: '507f1f77bcf86cd799439013', + }, + }), + ); + }); + it('recalculates unread count after markAllRead', async () => { const notificationsRepository = { markAllRead: jest.fn().mockResolvedValue(4), diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 0cef007..e6e2302 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -172,6 +172,7 @@ export class NotificationsService { recipientId: string, conversationId: string, previewText = '', + messageId?: string, ) { return this.create({ actorId, @@ -181,6 +182,11 @@ export class NotificationsService { resourceType: 'conversation', deepLink: `/chat/conversations/${conversationId}`, previewText, + metadata: { + type: 'message', + conversationId: String(conversationId), + ...(messageId ? { messageId: String(messageId) } : {}), + }, }); } diff --git a/src/modules/posts/dto/create-post.dto.ts b/src/modules/posts/dto/create-post.dto.ts index c20b75f..5c476fe 100644 --- a/src/modules/posts/dto/create-post.dto.ts +++ b/src/modules/posts/dto/create-post.dto.ts @@ -24,13 +24,13 @@ export class CreatePostDto { @Length(0, 2200) content?: string; - @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above post media' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Text shown above media' }) @IsOptional() @IsString() @Length(0, 2200) contentTop?: string; - @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below post media' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Text shown below media' }) @IsOptional() @IsString() @Length(0, 2200) diff --git a/src/modules/posts/dto/create-reel.dto.ts b/src/modules/posts/dto/create-reel.dto.ts index c68ce07..e52b9ba 100644 --- a/src/modules/posts/dto/create-reel.dto.ts +++ b/src/modules/posts/dto/create-reel.dto.ts @@ -22,13 +22,13 @@ export class CreateReelDto { @Length(0, 2200) content?: string; - @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above reel media' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Text shown above reel media' }) @IsOptional() @IsString() @Length(0, 2200) contentTop?: string; - @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below reel media' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Text shown below reel media' }) @IsOptional() @IsString() @Length(0, 2200) diff --git a/src/modules/posts/dto/post-query.dto.ts b/src/modules/posts/dto/post-query.dto.ts index 07fea53..c386009 100644 --- a/src/modules/posts/dto/post-query.dto.ts +++ b/src/modules/posts/dto/post-query.dto.ts @@ -1,6 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsOptional, IsString } from 'class-validator'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { PostType } from '../../../common/enums/post-type.enum'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; @@ -22,27 +21,18 @@ export type PostVisibilityFilter = (typeof POST_VISIBILITY_FILTERS)[number]; export const POST_MEDIA_TYPE_FILTERS = [...Object.values(PostType), 'reel'] as const; export type PostMediaTypeFilter = (typeof POST_MEDIA_TYPE_FILTERS)[number]; -const normalizePostTypeFilter = ({ value }: { value: unknown }) => - typeof value === 'string' && value.trim().toLowerCase() === 'reel' - ? PostType.VIDEO - : value; - export class PostQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: POST_VISIBILITY_FILTERS }) @IsOptional() @IsIn(POST_VISIBILITY_FILTERS) visibility?: PostVisibilityFilter; - @ApiPropertyOptional({ enum: PostType }) + @ApiPropertyOptional({ enum: POST_MEDIA_TYPE_FILTERS }) @IsOptional() - @Transform(normalizePostTypeFilter) - @IsEnum(PostType) - postType?: PostType; + @IsIn(POST_MEDIA_TYPE_FILTERS) + postType?: PostMediaTypeFilter; - @ApiPropertyOptional({ - enum: POST_MEDIA_TYPE_FILTERS, - description: 'Optional alias for postType. The reel value maps to video posts.', - }) + @ApiPropertyOptional({ enum: POST_MEDIA_TYPE_FILTERS }) @IsOptional() @IsIn(POST_MEDIA_TYPE_FILTERS) mediaType?: PostMediaTypeFilter; @@ -59,6 +49,6 @@ export class PostQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' }) @IsOptional() - @IsEnum(POST_SORT_FIELDS) + @IsIn(POST_SORT_FIELDS) sortBy?: PostSortField; } diff --git a/src/modules/posts/dto/update-post.dto.ts b/src/modules/posts/dto/update-post.dto.ts index 6e6d3b4..9b05a87 100644 --- a/src/modules/posts/dto/update-post.dto.ts +++ b/src/modules/posts/dto/update-post.dto.ts @@ -24,13 +24,13 @@ export class UpdatePostDto { @Length(1, 2200) content?: string; - @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above post media' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Text shown above media' }) @IsOptional() @IsString() @Length(0, 2200) contentTop?: string; - @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below post media' }) + @ApiPropertyOptional({ maxLength: 2200, description: 'Text shown below media' }) @IsOptional() @IsString() @Length(0, 2200) diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index ce4fd1b..864b461 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -60,8 +60,8 @@ export class PostsController { type: 'object', properties: { content: { type: 'string', example: 'First post #music' }, - contentTop: { type: 'string', example: 'Before the performance #oud' }, - contentBottom: { type: 'string', example: 'Full session caption' }, + contentTop: { type: 'string', example: 'Text above media' }, + contentBottom: { type: 'string', example: 'Text below media' }, visibility: { type: 'string', enum: ['public', 'followers', 'private'] }, imageUrls: { type: 'array', items: { type: 'string' } }, imageCaptions: { type: 'array', items: { type: 'string' } }, @@ -145,8 +145,8 @@ export class PostsController { type: 'object', properties: { content: { type: 'string', example: 'New reel from oud session #reel' }, - contentTop: { type: 'string', example: 'Live from the studio' }, - contentBottom: { type: 'string', example: 'New reel from oud session #reel' }, + contentTop: { type: 'string', example: 'Text above reel' }, + contentBottom: { type: 'string', example: 'Text below reel' }, visibility: { type: 'string', enum: ['public', 'followers', 'private'] }, videoUrl: { type: 'string', example: 'https://cdn.example.com/reel.mp4' }, durationSeconds: { type: 'number', example: 42 }, @@ -203,8 +203,8 @@ export class PostsController { type: 'object', properties: { content: { type: 'string', example: 'Updated content' }, - contentTop: { type: 'string', example: 'Updated top text' }, - contentBottom: { type: 'string', example: 'Updated bottom text' }, + contentTop: { type: 'string', example: 'Updated text above media' }, + contentBottom: { type: 'string', example: 'Updated text below media' }, visibility: { type: 'string', enum: ['public', 'followers', 'private'] }, imageUrls: { type: 'array', items: { type: 'string' } }, imageCaptions: { type: 'array', items: { type: 'string' } }, diff --git a/src/modules/posts/posts.service.spec.ts b/src/modules/posts/posts.service.spec.ts index 55012fa..a2c835e 100644 --- a/src/modules/posts/posts.service.spec.ts +++ b/src/modules/posts/posts.service.spec.ts @@ -1,21 +1,16 @@ import { Types } from 'mongoose'; +import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { PostSchema } from './schemas/post.schema'; import { PostsService } from './posts.service'; const createService = () => { const postsRepository = { - create: jest.fn((authorId: string, payload: Record) => - Promise.resolve({ - id: new Types.ObjectId().toString(), - authorId: new Types.ObjectId(authorId), - ...payload, - }), - ), findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findById: jest.fn(), updateById: jest.fn(), + create: jest.fn(), incrementShareCount: jest.fn().mockResolvedValue(undefined), }; const connection = { @@ -26,14 +21,14 @@ const createService = () => { insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }), }; const usersRepository = { - incrementPostsCount: jest.fn().mockResolvedValue(undefined), - findByUsernames: jest.fn().mockResolvedValue([]), - findMany: jest.fn().mockResolvedValue([]), findById: jest.fn().mockResolvedValue({ id: new Types.ObjectId().toString(), isDisabled: false, toObject: () => ({ _id: new Types.ObjectId(), username: 'viewer', name: 'Viewer' }), }), + findByUsernames: jest.fn().mockResolvedValue([]), + findMany: jest.fn().mockResolvedValue([]), + incrementPostsCount: jest.fn().mockResolvedValue(undefined), }; const notificationsService = { createShareNotification: jest.fn().mockResolvedValue(undefined), @@ -137,37 +132,86 @@ describe('PostsService archived profile posts', () => { ); }); - it('filters archived posts by postType image', async () => { + it('maps archived postType=reel to video posts', async () => { const userId = new Types.ObjectId().toString(); const { service, postsRepository } = createService(); - await service.findUserPosts(userId, { visibility: 'archived', postType: 'image' as any }, userId); + await service.findUserPosts( + userId, + { visibility: 'archived', postType: 'reel', page: 1, limit: 20 }, + userId, + ); expect(postsRepository.findMany).toHaveBeenCalledWith( expect.objectContaining({ isArchived: true, - postType: 'image', + postType: PostType.VIDEO, }), 0, 20, expect.any(Object), ); }); +}); - it('maps archived mediaType=reel to video posts', async () => { +describe('PostsService content placement', () => { + it('creates posts with contentTop and contentBottom and extracts text metadata from both', async () => { const userId = new Types.ObjectId().toString(); - const { service, postsRepository } = createService(); + const mentionedUserId = new Types.ObjectId().toString(); + const { service, postsRepository, usersRepository } = createService(); + postsRepository.create.mockResolvedValue({ id: 'post-1' }); + postsRepository.findById.mockResolvedValue({ id: 'post-1' }); + usersRepository.findByUsernames.mockResolvedValue([ + { id: mentionedUserId, username: 'artist', isDisabled: false }, + ]); - await service.findUserPosts(userId, { visibility: 'archived', mediaType: 'reel' as any }, userId); + await service.create(userId, { + contentTop: 'Top #oud @artist', + contentBottom: 'Bottom #maqam', + }); - expect(postsRepository.findMany).toHaveBeenCalledWith( + expect(postsRepository.create).toHaveBeenCalledWith( + userId, expect.objectContaining({ - isArchived: true, - postType: 'video', + content: '', + contentTop: 'Top #oud @artist', + contentBottom: 'Bottom #maqam', + hashtags: ['oud', 'maqam'], + mentionUsernames: ['artist'], + mentionedUserIds: expect.arrayContaining([expect.any(Types.ObjectId)]), + }), + ); + }); + + it('updates contentBottom while preserving legacy content fallback', async () => { + const userId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + const { service, postsRepository } = createService(); + postsRepository.findById.mockResolvedValue({ + id: postId, + authorId: new Types.ObjectId(userId), + content: 'legacy #old', + contentTop: '', + contentBottom: '', + imageUrls: [], + videoUrl: '', + audioUrl: '', + taggedUserIds: [], + collaboratorIds: [], + mentionUsernames: [], + mentionedUserIds: [], + }); + postsRepository.updateById.mockResolvedValue({ id: postId, content: 'legacy #old', contentBottom: 'new #fresh' }); + + await service.update(userId, postId, { contentBottom: 'new #fresh' }); + + expect(postsRepository.updateById).toHaveBeenCalledWith( + postId, + expect.objectContaining({ + content: 'legacy #old', + contentBottom: 'new #fresh', + hashtags: ['fresh', 'old'], }), - 0, - 20, - expect.any(Object), ); }); }); @@ -250,117 +294,6 @@ describe('PostsService post sharing', () => { }); }); -describe('PostsService post text placement', () => { - it('keeps legacy content-only posts as bottom-compatible content', async () => { - const userId = new Types.ObjectId().toString(); - const { service, postsRepository } = createService(); - - await service.create(userId, { content: 'Legacy caption #oud' }); - - expect(postsRepository.create).toHaveBeenCalledWith( - userId, - expect.objectContaining({ - content: 'Legacy caption #oud', - contentTop: '', - contentBottom: '', - hashtags: ['oud'], - }), - ); - }); - - it('creates a post with top text only', async () => { - const userId = new Types.ObjectId().toString(); - const { service, postsRepository } = createService(); - - await service.create(userId, { contentTop: 'Top line #intro' }); - - expect(postsRepository.create).toHaveBeenCalledWith( - userId, - expect.objectContaining({ - content: '', - contentTop: 'Top line #intro', - contentBottom: '', - hashtags: ['intro'], - }), - ); - }); - - it('creates a post with bottom text only', async () => { - const userId = new Types.ObjectId().toString(); - const { service, postsRepository } = createService(); - - await service.create(userId, { contentBottom: 'Bottom caption #outro' }); - - expect(postsRepository.create).toHaveBeenCalledWith( - userId, - expect.objectContaining({ - content: 'Bottom caption #outro', - contentTop: '', - contentBottom: 'Bottom caption #outro', - hashtags: ['outro'], - }), - ); - }); - - it('creates a post with top and bottom text together', async () => { - const userId = new Types.ObjectId().toString(); - const { service, postsRepository } = createService(); - - await service.create(userId, { - contentTop: 'Top #same', - contentBottom: 'Bottom #different', - }); - - expect(postsRepository.create).toHaveBeenCalledWith( - userId, - expect.objectContaining({ - content: 'Bottom #different', - contentTop: 'Top #same', - contentBottom: 'Bottom #different', - hashtags: expect.arrayContaining(['same', 'different']), - }), - ); - }); - - it('extracts hashtags and mentions from content, contentTop, and contentBottom without duplicates', async () => { - const userId = new Types.ObjectId().toString(); - const mentionedUserId = new Types.ObjectId().toString(); - const { service, postsRepository, usersRepository, notificationsService } = createService(); - usersRepository.findByUsernames.mockResolvedValue([ - { - id: mentionedUserId, - username: 'singer', - name: 'Singer', - isDisabled: false, - }, - ]); - - await service.create(userId, { - content: 'Legacy @singer #oud', - contentTop: 'Top @singer #oud', - contentBottom: 'Bottom #maqam', - }); - - expect(postsRepository.create).toHaveBeenCalledWith( - userId, - expect.objectContaining({ - mentionUsernames: ['singer'], - mentionedUserIds: [new Types.ObjectId(mentionedUserId)], - hashtags: expect.arrayContaining(['oud', 'maqam']), - }), - ); - expect(notificationsService.createMentionNotification).toHaveBeenCalledWith( - userId, - mentionedUserId, - expect.any(String), - expect.objectContaining({ - resourceType: 'post', - previewText: expect.stringContaining('Legacy @singer #oud'), - }), - ); - }); -}); - describe('Post schema response aliases', () => { it('returns isPinned as a stable alias for pinnedToProfile', () => { const transform = PostSchema.get('toObject')?.transform as ( @@ -377,23 +310,4 @@ describe('Post schema response aliases', () => { expect(ret.pinnedToProfile).toBe(true); expect(ret.isPinned).toBe(true); }); - - it('returns contentTop and contentBottom in serialized post responses', () => { - const transform = PostSchema.get('toObject')?.transform as ( - doc: unknown, - ret: Record, - ) => Record; - const ret = transform(null, { - postType: 'text', - content: 'Legacy caption', - contentTop: 'Top text', - contentBottom: 'Bottom text', - waveformPeaks: [], - mentionedUserIds: [], - }); - - expect(ret.content).toBe('Legacy caption'); - expect(ret.contentTop).toBe('Top text'); - expect(ret.contentBottom).toBe('Bottom text'); - }); }); diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index bbba896..240f740 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -178,12 +178,10 @@ export class PostsService { const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : []; const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || ''; const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || ''; + const finalContent = dto.content?.trim() ?? ''; const finalContentTop = dto.contentTop?.trim() ?? ''; const finalContentBottom = dto.contentBottom?.trim() ?? ''; - const finalLegacyContent = dto.content?.trim() ?? ''; - const finalContent = - typeof dto.contentBottom === 'string' ? finalContentBottom : finalLegacyContent; - const finalText = this.combinePostText(finalLegacyContent, finalContentTop, finalContentBottom); + const combinedText = this.combinePostText(finalContent, finalContentTop, finalContentBottom); const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId); const collaboratorIds = await this.normalizeUserIdList( dto.collaboratorIds, @@ -195,20 +193,20 @@ export class PostsService { const mentionResolution = await this.resolveMentionTargets( dto.mentionUsernames, dto.mentionedUserIds, - finalText, + combinedText, userId, ); const { location, latitude, longitude } = this.normalizeLocation(dto); - if (!finalText && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) { + if (!combinedText && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) { throw new BadRequestException('Post must contain caption or media'); } const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl); - const hashtags = this.extractHashtags(finalText); + const hashtags = this.extractHashtags(combinedText); const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { audioSourceBuffer: audioFile?.buffer, extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds, - waveformSeed: finalAudioUrl || finalText || `${userId}:${Date.now()}`, + waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`, thumbnailUrl: uploadedThumbnailUrl, }); @@ -268,7 +266,7 @@ export class PostsService { userId, post.id, mentionResolution.mentionedUsers, - finalText, + combinedText, ); return (await this.postsRepository.findById(post.id)) ?? post; } @@ -387,16 +385,12 @@ export class PostsService { ? null : existingThumbnailVariants; const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl); + const nextContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? ''); const nextContentTop = - typeof dto.contentTop === 'string' ? dto.contentTop.trim() : (post.contentTop ?? ''); + typeof dto.contentTop === 'string' ? dto.contentTop.trim() : ((post as any).contentTop ?? ''); const nextContentBottom = - typeof dto.contentBottom === 'string' ? dto.contentBottom.trim() : (post.contentBottom ?? ''); - const nextLegacyContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? ''); - const nextContent = - typeof dto.contentBottom === 'string' - ? nextContentBottom - : nextLegacyContent; - const nextText = this.combinePostText(nextLegacyContent, nextContentTop, nextContentBottom); + typeof dto.contentBottom === 'string' ? dto.contentBottom.trim() : ((post as any).contentBottom ?? ''); + const combinedText = this.combinePostText(nextContent, nextContentTop, nextContentBottom); const nextTaggedUserIds = typeof dto.taggedUserIds !== 'undefined' ? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId) @@ -426,7 +420,7 @@ export class PostsService { typeof dto.mentionUsernames !== 'undefined' || typeof dto.mentionedUserIds !== 'undefined'; const mentionResolution = shouldRecomputeMentions - ? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, nextText, userId) + ? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, combinedText, userId) : { mentionUsernames: previousMentionUsernames, mentionedUserIds: previousMentionedUserIds.map((id) => new Types.ObjectId(id)), @@ -441,7 +435,7 @@ export class PostsService { latitude: post.latitude ?? null, longitude: post.longitude ?? null, }); - if (!nextText && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) { + if (!combinedText && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) { throw new BadRequestException('Post must contain caption or media'); } const mediaMetadata = this.normalizeMediaMetadata( @@ -460,7 +454,7 @@ export class PostsService { { audioSourceBuffer: audioFile?.buffer, extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds, - waveformSeed: nextAudioUrl || nextText || post.id, + waveformSeed: nextAudioUrl || combinedText || post.id, thumbnailUrl: uploadedThumbnailUrl, }, ); @@ -492,10 +486,10 @@ export class PostsService { typeof dto.contentTop === 'string' || typeof dto.contentBottom === 'string' ) { - payload.hashtags = this.extractHashtags(nextText); + payload.hashtags = this.extractHashtags(combinedText); } if (hasImageUpdate) { - payload.hashtags = this.extractHashtags(nextText); + payload.hashtags = this.extractHashtags(combinedText); } if (hasVideoUpdate && !hasAudioUpdate) { @@ -603,7 +597,7 @@ export class PostsService { const nextMentionedUsers = mentionResolution.mentionedUsers.filter( (mentionedUser) => !previousMentionSet.has(mentionedUser.username), ); - await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, nextText); + await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, combinedText); } return updated; } @@ -688,12 +682,12 @@ export class PostsService { if (query.visibility && !archivedOnly) { filter.visibility = query.visibility; } - const resolvedPostType = this.resolvePostTypeFilter(query); - if (resolvedPostType) { - filter.postType = resolvedPostType; + const postTypeFilter = this.resolvePostTypeFilter(query.mediaType ?? query.postType); + if (postTypeFilter) { + filter.postType = postTypeFilter; } if (query.q) { - filter.$or = this.buildTextSearchFilter(query.q); + filter.content = { $regex: query.q.trim(), $options: 'i' }; } if (query.hashtag) { filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); @@ -721,21 +715,17 @@ export class PostsService { const skip = (page - 1) * limit; const filter: Record = {}; - const archivedOnly = query.visibility === 'archived'; - if (archivedOnly) { - filter.isArchived = true; - } else if (query.visibility) { + if (query.visibility) { filter.visibility = query.visibility; } - const resolvedPostType = this.resolvePostTypeFilter(query); - if (resolvedPostType) { - filter.postType = resolvedPostType; + if (query.postType) { + filter.postType = query.postType; } if (query.authorId) { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q?.trim()) { - filter.$or = this.buildTextSearchFilter(query.q); + filter.content = { $regex: query.q.trim(), $options: 'i' }; } if (query.hashtag?.trim()) { filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); @@ -810,7 +800,7 @@ export class PostsService { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q) { - filter.$or = this.buildTextSearchFilter(query.q); + filter.content = { $regex: query.q.trim(), $options: 'i' }; } const direction = resolveMongoSortDirection(query.sortOrder); const sortField = query.sortBy ?? 'createdAt'; @@ -1160,14 +1150,11 @@ export class PostsService { return PostType.TEXT; } - private resolvePostTypeFilter(query: Pick): PostType | undefined { - if (query.postType) { - return query.postType; + private resolvePostTypeFilter(input?: string): PostType | null { + if (!input) { + return null; } - if (query.mediaType === 'reel') { - return PostType.VIDEO; - } - return query.mediaType as PostType | undefined; + return input === 'reel' ? PostType.VIDEO : (input as PostType); } private normalizeMediaMetadata( @@ -1271,19 +1258,11 @@ export class PostsService { return Array.from(new Set(normalized)).slice(0, 30); } - private combinePostText(...values: Array): string { - return Array.from( - new Set( - values - .map((value) => value?.trim() ?? '') - .filter((value) => value.length > 0), - ), - ).join('\n\n'); - } - - private buildTextSearchFilter(query: string) { - const pattern = { $regex: query.trim(), $options: 'i' }; - return [{ content: pattern }, { contentTop: pattern }, { contentBottom: pattern }]; + private combinePostText(content = '', contentTop = '', contentBottom = ''): string { + return [contentTop, contentBottom, content] + .map((value) => value.trim()) + .filter(Boolean) + .join('\n'); } private normalizeMentionUsernames(input: string[] = []): string[] { diff --git a/src/modules/saves/saves.module.ts b/src/modules/saves/saves.module.ts index 47f3464..567c718 100644 --- a/src/modules/saves/saves.module.ts +++ b/src/modules/saves/saves.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; -import { NotificationsModule } from '../notifications/notifications.module'; import { BlocksModule } from '../blocks/blocks.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { PostsModule } from '../posts/posts.module'; import { Save, SaveSchema } from './schemas/save.schema'; import { SavesController } from './saves.controller'; diff --git a/src/modules/saves/saves.service.spec.ts b/src/modules/saves/saves.service.spec.ts index c7ce423..68b7373 100644 --- a/src/modules/saves/saves.service.spec.ts +++ b/src/modules/saves/saves.service.spec.ts @@ -8,13 +8,16 @@ describe('SavesService', () => { const postsRepository = { findById: jest.fn().mockResolvedValue(null), }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue(null), + }; const service = new SavesService( savesRepository as any, postsRepository as any, { bumpGlobalVersion: jest.fn() } as any, { createSaveNotification: jest.fn() } as any, - { hasBlockBetween: jest.fn().mockResolvedValue(false) } as any, + blocksRepository as any, ); await expect( @@ -25,4 +28,32 @@ describe('SavesService', () => { }); expect(savesRepository.findOne).not.toHaveBeenCalled(); }); + + it('prevents saving a post when either user blocked the other', async () => { + const userId = '507f1f77bcf86cd799439012'; + const ownerId = '507f191e810c19729de860ea'; + const postId = '507f1f77bcf86cd799439011'; + const savesRepository = { + findOne: jest.fn(), + }; + const postsRepository = { + findById: jest.fn().mockResolvedValue({ authorId: ownerId }), + }; + const blocksRepository = { + findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }), + }; + + const service = new SavesService( + savesRepository as any, + postsRepository as any, + { bumpGlobalVersion: jest.fn() } as any, + { createSaveNotification: jest.fn() } as any, + blocksRepository as any, + ); + + await expect(service.save(userId, { postId })).rejects.toThrow( + 'You cannot interact with this user', + ); + expect(savesRepository.findOne).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/saves/saves.service.ts b/src/modules/saves/saves.service.ts index 0295595..47f070b 100644 --- a/src/modules/saves/saves.service.ts +++ b/src/modules/saves/saves.service.ts @@ -1,11 +1,11 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Types } from 'mongoose'; import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; +import { BlocksRepository } from '../blocks/blocks.repository'; import { NotificationsService } from '../notifications/notifications.service'; -import { BlocksService } from '../blocks/blocks.service'; import { PostsRepository } from '../posts/posts.repository'; import { ToggleSaveDto } from './dto/toggle-save.dto'; import { SavesRepository } from './saves.repository'; @@ -19,7 +19,7 @@ export class SavesService { private readonly postsRepository: PostsRepository, private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, - private readonly blocksService: BlocksService, + private readonly blocksRepository: BlocksRepository, ) {} async toggle(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> { @@ -29,8 +29,7 @@ export class SavesService { async save(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> { const post = await this.getPostOrThrow(dto.postId); - const recipientId = this.extractEntityId(post.authorId); - await this.assertNoBlockBetween(userId, recipientId); + await this.assertCanSave(userId, post); const existing = await this.savesRepository.findOne(userId, dto.postId); if (existing) { @@ -40,11 +39,8 @@ export class SavesService { await this.savesRepository.create(userId, dto.postId); await this.postsRepository.incrementSavesCount(dto.postId, 1); await this.feedVersionService.bumpGlobalVersion(); - if ( - recipientId && - recipientId !== userId && - !(await this.blocksService.hasBlockBetween(userId, recipientId)) - ) { + const recipientId = this.extractEntityId(post.authorId); + if (recipientId && recipientId !== userId) { try { await this.notificationsService.createSaveNotification(userId, recipientId, dto.postId, { resourceType: 'post', @@ -123,12 +119,15 @@ export class SavesService { return post; } - private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise { - if (!targetUserId || actorId === targetUserId) { + private async assertCanSave(userId: string, post: any): Promise { + const authorId = this.extractEntityId(post.authorId); + if (!authorId || authorId === userId) { return; } - if (await this.blocksService.hasBlockBetween(actorId, targetUserId)) { - throw new NotFoundException('Post not found'); + + const block = await this.blocksRepository.findAnyBetween(userId, authorId); + if (block) { + throw new ForbiddenException('You cannot interact with this user'); } } diff --git a/src/modules/users/dto/create-user.dto.ts b/src/modules/users/dto/create-user.dto.ts index a7b6c28..81e1b07 100644 --- a/src/modules/users/dto/create-user.dto.ts +++ b/src/modules/users/dto/create-user.dto.ts @@ -127,4 +127,10 @@ export class CreateUserDto { @IsArray() @IsString({ each: true }) favoriteMaqamat?: string[]; + + @ApiProperty({ required: false, example: 'طربي', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + preferredMood?: string; } diff --git a/src/modules/users/dto/music-setup.dto.ts b/src/modules/users/dto/music-setup.dto.ts index cf4ca88..739e3c5 100644 --- a/src/modules/users/dto/music-setup.dto.ts +++ b/src/modules/users/dto/music-setup.dto.ts @@ -33,7 +33,7 @@ export class MusicSetupDto { @IsString({ each: true }) favoriteMaqamat?: string[]; - @ApiPropertyOptional({ example: 'Tarab', maxLength: 80 }) + @ApiPropertyOptional({ example: 'طربي', maxLength: 80 }) @IsOptional() @IsString() @Length(0, 80) diff --git a/src/modules/users/dto/profile-setup.dto.ts b/src/modules/users/dto/profile-setup.dto.ts index a0cbd10..4c78813 100644 --- a/src/modules/users/dto/profile-setup.dto.ts +++ b/src/modules/users/dto/profile-setup.dto.ts @@ -25,18 +25,18 @@ export class ProfileSetupDto { @Length(0, 150) bio?: string; - @ApiPropertyOptional({ example: 'Tarab', maxLength: 80 }) - @IsOptional() - @IsString() - @Length(0, 80) - preferredMood?: string; - @ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' }) @IsOptional() @IsString() @Length(0, 120) location?: string; + @ApiPropertyOptional({ example: 'طربي', maxLength: 80 }) + @IsOptional() + @IsString() + @Length(0, 80) + preferredMood?: string; + @ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 }) @Transform(({ value }) => (typeof value === 'string' ? Number.parseFloat(value) : value)) @Type(() => Number) diff --git a/src/modules/users/dto/update-user.dto.ts b/src/modules/users/dto/update-user.dto.ts index c34cb69..0b74778 100644 --- a/src/modules/users/dto/update-user.dto.ts +++ b/src/modules/users/dto/update-user.dto.ts @@ -120,7 +120,7 @@ export class UpdateUserDto { @IsString({ each: true }) favoriteMaqamat?: string[]; - @ApiPropertyOptional({ example: 'Tarab', maxLength: 80 }) + @ApiPropertyOptional({ example: 'طربي', maxLength: 80 }) @IsOptional() @IsString() @Length(0, 80) diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index 2022843..bbe65c5 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -32,6 +32,10 @@ const createService = (options: { followingCount: 4, isVerified: true, isDisabled: false, + preferredMood: '', + get(field: string) { + return (this as Record)[field]; + }, toObject() { return { _id: userId, @@ -44,6 +48,7 @@ const createService = (options: { followersCount: this.followersCount, followingCount: this.followingCount, isVerified: this.isVerified, + preferredMood: this.preferredMood, }; }, }; @@ -103,7 +108,6 @@ const createService = (options: { Promise.resolve({ ...user, ...payload, - toObject: () => ({ ...user.toObject(), ...payload }), }), ), }; @@ -126,6 +130,17 @@ const createService = (options: { }; }; +describe('UsersService preferredMood', () => { + it('updates and returns preferredMood from music setup', async () => { + const { service, userId, usersRepository } = createService(); + + const result = await service.updateMusicSetup(userId, { preferredMood: 'طربي' }); + + expect(usersRepository.updateById).toHaveBeenCalledWith(userId, { preferredMood: 'طربي' }); + expect((result as any).preferredMood).toBe('طربي'); + }); +}); + describe('UsersService artist dashboard', () => { it('returns zeros and safe empty arrays for an authenticated user with no posts', async () => { const { service, userId } = createService(); @@ -314,14 +329,3 @@ describe('UsersService profile sharing', () => { ); }); }); - -describe('UsersService preferredMood', () => { - it('updates preferredMood through music setup and returns it', async () => { - const { service, userId, usersRepository } = createService(); - - const result = await service.updateMusicSetup(userId, { preferredMood: 'Tarab' }); - - expect(usersRepository.updateById).toHaveBeenCalledWith(userId, { preferredMood: 'Tarab' }); - expect(result.toObject()).toEqual(expect.objectContaining({ preferredMood: 'Tarab' })); - }); -}); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index a5aa8c9..cfee639 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -94,6 +94,7 @@ export class UsersService { musicGenres: dto.musicGenres ?? [], favoriteInstruments: dto.favoriteInstruments ?? [], favoriteMaqamat: dto.favoriteMaqamat ?? [], + preferredMood: dto.preferredMood ?? '', role: dto.role ?? UserRole.USER, isDisabled: false, disabledReason: '', @@ -331,7 +332,7 @@ export class UsersService { coverImageFile?: UploadedImageFile, ): Promise { const currentUser = await this.findByIdOrFail(userId); - const payload = await this.prepareManagedUserUpdatePayload(userId, dto); + const payload: Record = { ...dto }; const uploadedImageUrls = await this.attachUploadedProfileImages( payload, avatarFile,