Add Flutter Figma backend support
This commit is contained in:
@@ -5,7 +5,4 @@ export enum MusicRole {
|
|||||||
LYRICIST = 'lyricist',
|
LYRICIST = 'lyricist',
|
||||||
PRODUCER = 'producer',
|
PRODUCER = 'producer',
|
||||||
ARRANGER = 'arranger',
|
ARRANGER = 'arranger',
|
||||||
TEACHER = 'teacher',
|
|
||||||
STUDENT = 'student',
|
|
||||||
CONTENT_CREATOR = 'content_creator',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ export class AuthController {
|
|||||||
return { message: 'Logged out successfully' };
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('forgot-password')
|
@Post('forgot-password')
|
||||||
@Throttle(8, 60_000)
|
@Throttle(8, 60_000)
|
||||||
@@ -83,14 +91,6 @@ export class AuthController {
|
|||||||
return this.authService.resetPassword(dto);
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('send-email-verification')
|
@Post('send-email-verification')
|
||||||
@Throttle(8, 60_000)
|
@Throttle(8, 60_000)
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { hashValue } from '../../common/utils/hash.util';
|
import { hashValue } from '../../common/utils/hash.util';
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
const createService = async () => {
|
describe('AuthService change password', () => {
|
||||||
const userId = '507f1f77bcf86cd799439011';
|
const createService = (passwordHash: string) => {
|
||||||
const usersService = {
|
const usersService = {
|
||||||
findByIdWithPassword: jest.fn().mockResolvedValue({
|
findByIdWithPassword: jest.fn().mockResolvedValue({ id: 'user-1', password: passwordHash }),
|
||||||
id: userId,
|
updatePassword: jest.fn().mockResolvedValue(undefined),
|
||||||
password: await hashValue('OldPassword123', 4),
|
|
||||||
isDisabled: false,
|
|
||||||
}),
|
|
||||||
updatePassword: jest.fn(),
|
|
||||||
};
|
};
|
||||||
const authRepository = {
|
const authRepository = {
|
||||||
revokeAllUserTokens: jest.fn(),
|
revokeAllUserTokens: jest.fn().mockResolvedValue(undefined),
|
||||||
removeExpiredAndRevoked: jest.fn(),
|
removeExpiredAndRevoked: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const configService = {
|
const configService = {
|
||||||
get: jest.fn((key: string) => (key === 'security.bcryptSaltRounds' ? 4 : undefined)),
|
get: jest.fn((key: string) => (key === 'security.bcryptSaltRounds' ? 8 : undefined)),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new AuthService(
|
const service = new AuthService(
|
||||||
usersService as any,
|
usersService as any,
|
||||||
authRepository as any,
|
authRepository as any,
|
||||||
@@ -30,40 +25,40 @@ const createService = async () => {
|
|||||||
{} as any,
|
{} as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { service, userId, usersService, authRepository };
|
return { service, usersService, authRepository };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('AuthService changePassword', () => {
|
it('changes password and does not return the hash', async () => {
|
||||||
it('changes password, revokes refresh tokens, and returns no password hash', async () => {
|
const passwordHash = await hashValue('OldStrongPass123!', 8);
|
||||||
const { service, userId, usersService, authRepository } = await createService();
|
const { service, usersService, authRepository } = createService(passwordHash);
|
||||||
|
|
||||||
const result = await service.changePassword(userId, {
|
const result = await service.changePassword('user-1', {
|
||||||
currentPassword: 'OldPassword123',
|
currentPassword: 'OldStrongPass123!',
|
||||||
newPassword: 'NewPassword123',
|
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).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 () => {
|
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(
|
await expect(
|
||||||
service.changePassword(userId, {
|
service.changePassword('user-1', {
|
||||||
currentPassword: 'WrongPassword123',
|
currentPassword: 'WrongStrongPass123!',
|
||||||
newPassword: 'NewPassword123',
|
newPassword: 'NewStrongPass123!',
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
expect(usersService.updatePassword).not.toHaveBeenCalled();
|
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(), {
|
const dto = Object.assign(new ChangePasswordDto(), {
|
||||||
currentPassword: 'OldPassword123',
|
currentPassword: 'OldStrongPass123!',
|
||||||
newPassword: 'weakpass',
|
newPassword: 'weakpass',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<number>('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: {
|
async loginWithGoogle(googleUser: {
|
||||||
googleId: string;
|
googleId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -540,29 +560,6 @@ export class AuthService {
|
|||||||
return { message: 'Password reset successfully' };
|
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<number>('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(
|
private async generateAndStoreTokenPair(
|
||||||
userId: string,
|
userId: string,
|
||||||
username: string,
|
username: string,
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString, Length, Matches } from 'class-validator';
|
import { IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
|
|
||||||
|
|
||||||
export class ChangePasswordDto {
|
export class ChangePasswordDto {
|
||||||
@ApiProperty({ minLength: 8, maxLength: 64 })
|
@ApiProperty({ minLength: 8, example: 'OldStrongPass123!' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(8, 64)
|
@Length(8, 64)
|
||||||
currentPassword!: string;
|
currentPassword!: string;
|
||||||
|
|
||||||
@ApiProperty({ minLength: 8, maxLength: 64 })
|
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(8, 64)
|
@Length(8, 64)
|
||||||
@Matches(PASSWORD_PATTERN, {
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
|
||||||
message: 'newPassword must contain uppercase, lowercase, and number characters',
|
message: 'newPassword must contain uppercase, lowercase, and number characters',
|
||||||
})
|
})
|
||||||
newPassword!: string;
|
newPassword!: string;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
|||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
import { BlocksService } from './blocks.service';
|
|
||||||
import { BlockListQueryDto } from './dto/block-list-query.dto';
|
import { BlockListQueryDto } from './dto/block-list-query.dto';
|
||||||
|
import { BlocksService } from './blocks.service';
|
||||||
|
|
||||||
@ApiTags('Blocks')
|
@ApiTags('Blocks')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import { Model, PipelineStage, Types } from 'mongoose';
|
import { FilterQuery, Model, Types } from 'mongoose';
|
||||||
import { Block, BlockDocument } from './schemas/block.schema';
|
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()
|
@Injectable()
|
||||||
export class BlocksRepository {
|
export class BlocksRepository {
|
||||||
constructor(@InjectModel(Block.name) private readonly blockModel: Model<BlockDocument>) {}
|
constructor(@InjectModel(Block.name) private readonly blockModel: Model<BlockDocument>) {}
|
||||||
@@ -38,6 +27,51 @@ export class BlocksRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findBlockedUsers(
|
||||||
|
blockerId: string,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
search?: string,
|
||||||
|
): Promise<Array<BlockDocument | Record<string, unknown>>> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
async create(blockerId: string, blockedId: string): Promise<void> {
|
||||||
await this.blockModel
|
await this.blockModel
|
||||||
.updateOne(
|
.updateOne(
|
||||||
@@ -75,46 +109,6 @@ export class BlocksRepository {
|
|||||||
return rows.map((row) => row.blockedId.toString());
|
return rows.map((row) => row.blockedId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBlockedUsers(
|
|
||||||
blockerId: string,
|
|
||||||
skip: number,
|
|
||||||
limit: number,
|
|
||||||
search?: string,
|
|
||||||
): Promise<BlockedUserRow[]> {
|
|
||||||
return this.blockModel
|
|
||||||
.aggregate<BlockedUserRow>([
|
|
||||||
{ $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<number> {
|
|
||||||
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<string[]> {
|
async findBlockingOrBlockedIds(userId: string): Promise<string[]> {
|
||||||
const userObjectId = new Types.ObjectId(userId);
|
const userObjectId = new Types.ObjectId(userId);
|
||||||
const rows = await this.blockModel
|
const rows = await this.blockModel
|
||||||
@@ -132,8 +126,16 @@ export class BlocksRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private blockedUserLookupStages(search?: string): PipelineStage[] {
|
private async aggregateBlockedUsers(
|
||||||
const stages: PipelineStage[] = [
|
blockerId: string,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
search: string,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const trimmedSearch = search.trim();
|
||||||
|
return this.blockModel
|
||||||
|
.aggregate<Record<string, unknown>>([
|
||||||
|
{ $match: { blockerId: new Types.ObjectId(blockerId) } },
|
||||||
{
|
{
|
||||||
$lookup: {
|
$lookup: {
|
||||||
from: 'users',
|
from: 'users',
|
||||||
@@ -143,27 +145,33 @@ export class BlocksRepository {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ $unwind: '$blockedUser' },
|
{ $unwind: '$blockedUser' },
|
||||||
{ $match: { 'blockedUser.isDisabled': { $ne: true } } },
|
{ $match: this.buildBlockedUserSearchMatch(trimmedSearch) },
|
||||||
];
|
{ $sort: { createdAt: -1 } },
|
||||||
const searchFilter = this.buildBlockedUserSearch(search);
|
{ $skip: skip },
|
||||||
if (searchFilter) {
|
{ $limit: limit },
|
||||||
stages.push({ $match: searchFilter });
|
{
|
||||||
}
|
$project: {
|
||||||
return stages;
|
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 {
|
private buildBlockedUserSearchMatch(search: string): FilterQuery<BlockDocument> {
|
||||||
const q = search?.trim();
|
|
||||||
if (!q) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const regex = { $regex: q, $options: 'i' };
|
|
||||||
return {
|
return {
|
||||||
$or: [
|
$or: [
|
||||||
{ 'blockedUser.name': regex },
|
{ 'blockedUser.name': { $regex: search, $options: 'i' } },
|
||||||
{ 'blockedUser.username': regex },
|
{ 'blockedUser.username': { $regex: search, $options: 'i' } },
|
||||||
{ 'blockedUser.stageName': regex },
|
{ 'blockedUser.stageName': { $regex: search, $options: 'i' } },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,53 @@
|
|||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
import { BlocksService } from './blocks.service';
|
import { BlocksService } from './blocks.service';
|
||||||
|
|
||||||
describe('BlocksService listBlockedUsers', () => {
|
describe('BlocksService list', () => {
|
||||||
it('returns blocked users with pagination metadata', async () => {
|
it('returns paginated blocked users without sensitive fields', async () => {
|
||||||
const currentUserId = new Types.ObjectId().toString();
|
const blockedId = new Types.ObjectId();
|
||||||
const blockedUserId = new Types.ObjectId();
|
const createdAt = new Date('2026-06-01T10:00:00.000Z');
|
||||||
const blockedAt = new Date('2026-06-01T00:00:00.000Z');
|
|
||||||
const blocksRepository = {
|
const blocksRepository = {
|
||||||
findBlockedUsers: jest.fn().mockResolvedValue([
|
findBlockedUsers: jest.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
blockedUser: {
|
blockedId: {
|
||||||
_id: blockedUserId,
|
_id: blockedId,
|
||||||
name: 'Blocked User',
|
name: 'Artist',
|
||||||
username: 'blocked_user',
|
username: 'artist',
|
||||||
avatar: '',
|
avatar: '/uploads/avatar.jpg',
|
||||||
stageName: 'Stage',
|
stageName: 'Stage',
|
||||||
|
email: 'hidden@example.com',
|
||||||
},
|
},
|
||||||
createdAt: blockedAt,
|
createdAt,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
countBlockedUsers: jest.fn().mockResolvedValue(1),
|
countBlockedUsers: jest.fn().mockResolvedValue(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new BlocksService(blocksRepository as any, {} as any);
|
const service = new BlocksService(blocksRepository as any, {} as any);
|
||||||
|
|
||||||
await expect(service.listBlockedUsers(currentUserId, { page: 1, limit: 20 })).resolves.toEqual({
|
const result = await service.listBlockedUsers(new Types.ObjectId().toString(), {
|
||||||
items: [
|
page: 1,
|
||||||
{
|
limit: 20,
|
||||||
userId: blockedUserId.toString(),
|
|
||||||
name: 'Blocked User',
|
|
||||||
username: 'blocked_user',
|
|
||||||
avatar: '',
|
|
||||||
stageName: 'Stage',
|
|
||||||
blockedAt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pagination: { page: 1, limit: 20, total: 1, pages: 1 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes a user from the block list through unblock', async () => {
|
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('unblock removes the user from future list results through repository deletion', async () => {
|
||||||
const currentUserId = new Types.ObjectId().toString();
|
const currentUserId = new Types.ObjectId().toString();
|
||||||
const targetUserId = new Types.ObjectId().toString();
|
const targetUserId = new Types.ObjectId().toString();
|
||||||
const blocksRepository = {
|
const blocksRepository = {
|
||||||
remove: jest.fn(),
|
remove: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new BlocksService(blocksRepository as any, {} as any);
|
const service = new BlocksService(blocksRepository as any, {} as any);
|
||||||
|
|
||||||
await expect(service.unblock(currentUserId, targetUserId)).resolves.toEqual({
|
await expect(service.unblock(currentUserId, targetUserId)).resolves.toEqual({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||||
import { UsersRepository } from '../users/users.repository';
|
import { UsersRepository } from '../users/users.repository';
|
||||||
import { BlocksRepository } from './blocks.repository';
|
|
||||||
import { BlockListQueryDto } from './dto/block-list-query.dto';
|
import { BlockListQueryDto } from './dto/block-list-query.dto';
|
||||||
|
import { BlocksRepository } from './blocks.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlocksService {
|
export class BlocksService {
|
||||||
@@ -29,36 +30,6 @@ export class BlocksService {
|
|||||||
return { blocked: false, targetUserId };
|
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) {
|
async getStatus(currentUserId: string, targetUserId: string) {
|
||||||
this.assertValidTarget(currentUserId, targetUserId);
|
this.assertValidTarget(currentUserId, targetUserId);
|
||||||
const [iBlocked, blockedMe] = await Promise.all([
|
const [iBlocked, blockedMe] = await Promise.all([
|
||||||
@@ -69,6 +40,26 @@ export class BlocksService {
|
|||||||
return { targetUserId, iBlocked: !!iBlocked, blockedMe: !!blockedMe };
|
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<string[]> {
|
async getBlockedIds(currentUserId: string): Promise<string[]> {
|
||||||
return this.blocksRepository.findBlockedIds(currentUserId);
|
return this.blocksRepository.findBlockedIds(currentUserId);
|
||||||
}
|
}
|
||||||
@@ -77,16 +68,6 @@ export class BlocksService {
|
|||||||
return this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
|
return this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasBlockBetween(currentUserId: string, targetUserId: string): Promise<boolean> {
|
|
||||||
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<void> {
|
async assertNoBlockBetween(currentUserId: string, targetUserId: string): Promise<void> {
|
||||||
this.assertValidTarget(currentUserId, targetUserId);
|
this.assertValidTarget(currentUserId, targetUserId);
|
||||||
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
|
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
|
||||||
@@ -103,4 +84,17 @@ export class BlocksService {
|
|||||||
throw new BadRequestException('You cannot block yourself');
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IsOptional, IsString } from 'class-validator';
|
|||||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
export class BlockListQueryDto extends PaginationQueryDto {
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ describe('ChatService realtime message broadcasting', () => {
|
|||||||
expect(result).toBe(message);
|
expect(result).toBe(message);
|
||||||
expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1);
|
expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledWith(conversationId, message);
|
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 () => {
|
it('emits new_message after a media upload message is created through the service', async () => {
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export class ChatService {
|
|||||||
conversation.participantIds.map((id) => id.toString()),
|
conversation.participantIds.map((id) => id.toString()),
|
||||||
conversation.id,
|
conversation.id,
|
||||||
preview,
|
preview,
|
||||||
|
message.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
@@ -480,6 +481,7 @@ export class ChatService {
|
|||||||
participantIds: string[],
|
participantIds: string[],
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
previewText: string,
|
previewText: string,
|
||||||
|
messageId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const recipientId of participantIds) {
|
for (const recipientId of participantIds) {
|
||||||
if (recipientId === actorId) {
|
if (recipientId === actorId) {
|
||||||
@@ -492,6 +494,7 @@ export class ChatService {
|
|||||||
recipientId,
|
recipientId,
|
||||||
conversationId,
|
conversationId,
|
||||||
previewText.slice(0, 160),
|
previewText.slice(0, 160),
|
||||||
|
messageId,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ describe('CommentsService', () => {
|
|||||||
const followsRepository = {
|
const followsRepository = {
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
};
|
};
|
||||||
const blocksService = {
|
const blocksRepository = {
|
||||||
hasBlockBetween: jest.fn().mockResolvedValue(false),
|
findAnyBetween: jest.fn().mockResolvedValue(null),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new CommentsService(
|
const service = new CommentsService(
|
||||||
@@ -56,7 +56,7 @@ describe('CommentsService', () => {
|
|||||||
notificationsService as any,
|
notificationsService as any,
|
||||||
usersRepository as any,
|
usersRepository as any,
|
||||||
followsRepository as any,
|
followsRepository as any,
|
||||||
blocksService as any,
|
blocksRepository as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.update(userId, commentId, {
|
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 userId = new Types.ObjectId().toString();
|
||||||
const authorId = new Types.ObjectId().toString();
|
const ownerId = new Types.ObjectId().toString();
|
||||||
const postId = new Types.ObjectId().toString();
|
const postId = new Types.ObjectId().toString();
|
||||||
const commentsRepository = {
|
const commentsRepository = {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
};
|
};
|
||||||
const postsRepository = {
|
const postsRepository = {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
id: postId,
|
_id: new Types.ObjectId(postId),
|
||||||
authorId: new Types.ObjectId(authorId),
|
authorId: new Types.ObjectId(ownerId),
|
||||||
commentsDisabled: false,
|
commentsDisabled: false,
|
||||||
commentsFollowersOnly: false,
|
commentsFollowersOnly: false,
|
||||||
}),
|
}),
|
||||||
setCommentsCount: jest.fn(),
|
|
||||||
};
|
};
|
||||||
const blocksService = {
|
const blocksRepository = {
|
||||||
hasBlockBetween: jest.fn().mockResolvedValue(true),
|
findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new CommentsService(
|
const service = new CommentsService(
|
||||||
@@ -115,12 +114,12 @@ describe('CommentsService', () => {
|
|||||||
{ createCommentNotification: jest.fn(), createMentionNotification: jest.fn() } as any,
|
{ createCommentNotification: jest.fn(), createMentionNotification: jest.fn() } as any,
|
||||||
{ findByUsernames: jest.fn() } as any,
|
{ findByUsernames: jest.fn() } as any,
|
||||||
{ findOne: jest.fn() } as any,
|
{ findOne: jest.fn() } as any,
|
||||||
blocksService as any,
|
blocksRepository as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
|
||||||
service.create(userId, { postId, content: 'Blocked comment' }),
|
'You cannot interact with this user',
|
||||||
).rejects.toThrow('Post not found');
|
);
|
||||||
expect(commentsRepository.create).not.toHaveBeenCalled();
|
expect(commentsRepository.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
|||||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { BlocksService } from '../blocks/blocks.service';
|
|
||||||
import { FollowsRepository } from '../follows/follows.repository';
|
import { FollowsRepository } from '../follows/follows.repository';
|
||||||
import { PostsRepository } from '../posts/posts.repository';
|
import { PostsRepository } from '../posts/posts.repository';
|
||||||
import { UsersRepository } from '../users/users.repository';
|
import { UsersRepository } from '../users/users.repository';
|
||||||
|
import { BlocksRepository } from '../blocks/blocks.repository';
|
||||||
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||||
import { CommentQueryDto, CommentSortBy } from './dto/comment-query.dto';
|
import { CommentQueryDto, CommentSortBy } from './dto/comment-query.dto';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
@@ -49,7 +49,7 @@ export class CommentsService {
|
|||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly followsRepository: FollowsRepository,
|
private readonly followsRepository: FollowsRepository,
|
||||||
private readonly blocksService: BlocksService,
|
private readonly blocksRepository: BlocksRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, dto: CreateCommentDto) {
|
async create(userId: string, dto: CreateCommentDto) {
|
||||||
@@ -65,8 +65,8 @@ export class CommentsService {
|
|||||||
if (!parent || parent.postId.toString() !== dto.postId) {
|
if (!parent || parent.postId.toString() !== dto.postId) {
|
||||||
throw new NotFoundException('Parent comment not found');
|
throw new NotFoundException('Parent comment not found');
|
||||||
}
|
}
|
||||||
await this.assertNoBlockBetween(userId, parent.authorId.toString());
|
|
||||||
parentRecipientId = parent.authorId.toString();
|
parentRecipientId = parent.authorId.toString();
|
||||||
|
await this.assertNoBlockBetween(userId, parentRecipientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = dto.content.trim();
|
const content = dto.content.trim();
|
||||||
@@ -393,28 +393,17 @@ export class CommentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authorId = this.extractEntityId(post.authorId);
|
const authorId = this.extractEntityId(post.authorId);
|
||||||
if (!post.commentsFollowersOnly || authorId === userId) {
|
|
||||||
await this.assertNoBlockBetween(userId, authorId);
|
await this.assertNoBlockBetween(userId, authorId);
|
||||||
|
if (!post.commentsFollowersOnly || authorId === userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertNoBlockBetween(userId, authorId);
|
|
||||||
const followsAuthor = await this.followsRepository.findOne(userId, authorId);
|
const followsAuthor = await this.followsRepository.findOne(userId, authorId);
|
||||||
if (!followsAuthor) {
|
if (!followsAuthor) {
|
||||||
throw new ForbiddenException('Only followers can comment on this post');
|
throw new ForbiddenException('Only followers can comment on this post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise<void> {
|
|
||||||
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 {
|
private matchesCommentFilter(content: string, keywords: string[] = []): boolean {
|
||||||
const normalized = content.toLowerCase();
|
const normalized = content.toLowerCase();
|
||||||
return keywords
|
return keywords
|
||||||
@@ -561,9 +550,6 @@ export class CommentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const recipientId of recipients) {
|
for (const recipientId of recipients) {
|
||||||
if (await this.blocksService.hasBlockBetween(actorId, recipientId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
|
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
|
||||||
resourceType: 'post',
|
resourceType: 'post',
|
||||||
@@ -650,9 +636,6 @@ export class CommentsService {
|
|||||||
if (excludedRecipientIds.has(mentionedUser.id)) {
|
if (excludedRecipientIds.has(mentionedUser.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (await this.blocksService.hasBlockBetween(actorId, mentionedUser.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
|
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
|
||||||
@@ -670,6 +653,16 @@ export class CommentsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertNoBlockBetween(userId: string, targetUserId: string): Promise<void> {
|
||||||
|
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 {
|
private extractEntityId(value: unknown): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -91,28 +91,4 @@ describe('FollowsService', () => {
|
|||||||
expect(followsRepository.create).not.toHaveBeenCalled();
|
expect(followsRepository.create).not.toHaveBeenCalled();
|
||||||
expect(outboxService.enqueueFollowNotification).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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { CommentsModule } from '../comments/comments.module';
|
import { CommentsModule } from '../comments/comments.module';
|
||||||
import { BlocksModule } from '../blocks/blocks.module';
|
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { PostsModule } from '../posts/posts.module';
|
import { PostsModule } from '../posts/posts.module';
|
||||||
|
import { BlocksModule } from '../blocks/blocks.module';
|
||||||
import { Like, LikeSchema } from './schemas/like.schema';
|
import { Like, LikeSchema } from './schemas/like.schema';
|
||||||
import { LikesController } from './likes.controller';
|
import { LikesController } from './likes.controller';
|
||||||
import { LikesRepository } from './likes.repository';
|
import { LikesRepository } from './likes.repository';
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ describe('LikesService', () => {
|
|||||||
const commentsRepository = {
|
const commentsRepository = {
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const blocksRepository = {
|
||||||
|
findAnyBetween: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new LikesService(
|
const service = new LikesService(
|
||||||
likesRepository as any,
|
likesRepository as any,
|
||||||
@@ -18,7 +21,7 @@ describe('LikesService', () => {
|
|||||||
commentsRepository as any,
|
commentsRepository as any,
|
||||||
{ bumpGlobalVersion: jest.fn() } as any,
|
{ bumpGlobalVersion: jest.fn() } as any,
|
||||||
{ createLikeNotification: jest.fn() } as any,
|
{ createLikeNotification: jest.fn() } as any,
|
||||||
{ hasBlockBetween: jest.fn().mockResolvedValue(false) } as any,
|
blocksRepository as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -32,34 +35,35 @@ describe('LikesService', () => {
|
|||||||
expect(likesRepository.findOne).not.toHaveBeenCalled();
|
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 = {
|
const likesRepository = {
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
create: jest.fn(),
|
|
||||||
};
|
};
|
||||||
const postsRepository = {
|
const postsRepository = {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({ authorId: ownerId }),
|
||||||
id: '507f1f77bcf86cd799439012',
|
|
||||||
authorId: '507f1f77bcf86cd799439013',
|
|
||||||
content: 'Post',
|
|
||||||
}),
|
|
||||||
incrementLikesCount: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
const commentsRepository = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
const blocksRepository = {
|
||||||
|
findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new LikesService(
|
const service = new LikesService(
|
||||||
likesRepository as any,
|
likesRepository as any,
|
||||||
postsRepository as any,
|
postsRepository as any,
|
||||||
{ findById: jest.fn() } as any,
|
commentsRepository as any,
|
||||||
{ bumpGlobalVersion: jest.fn() } as any,
|
{ bumpGlobalVersion: jest.fn() } as any,
|
||||||
{ createLikeNotification: jest.fn() } as any,
|
{ createLikeNotification: jest.fn() } as any,
|
||||||
{ hasBlockBetween: jest.fn().mockResolvedValue(true) } as any,
|
blocksRepository as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(service.like(userId, { targetId: postId, targetType: 'post' })).rejects.toThrow(
|
||||||
service.like('507f1f77bcf86cd799439011', {
|
'You cannot interact with this user',
|
||||||
targetId: '507f1f77bcf86cd799439012',
|
);
|
||||||
targetType: 'post',
|
expect(likesRepository.findOne).not.toHaveBeenCalled();
|
||||||
}),
|
|
||||||
).rejects.toThrow('Target not found');
|
|
||||||
expect(likesRepository.create).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
import { ReactionType } from '../../common/enums/reaction-type.enum';
|
import { ReactionType } from '../../common/enums/reaction-type.enum';
|
||||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.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 { CommentsRepository } from '../comments/comments.repository';
|
||||||
import { PostsRepository } from '../posts/posts.repository';
|
import { PostsRepository } from '../posts/posts.repository';
|
||||||
import { LikesRepository } from './likes.repository';
|
import { LikesRepository } from './likes.repository';
|
||||||
@@ -19,7 +19,7 @@ export class LikesService {
|
|||||||
private readonly commentsRepository: CommentsRepository,
|
private readonly commentsRepository: CommentsRepository,
|
||||||
private readonly feedVersionService: FeedVersionService,
|
private readonly feedVersionService: FeedVersionService,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
private readonly blocksService: BlocksService,
|
private readonly blocksRepository: BlocksRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async toggle(userId: string, dto: ToggleLikeDto) {
|
async toggle(userId: string, dto: ToggleLikeDto) {
|
||||||
@@ -29,8 +29,8 @@ export class LikesService {
|
|||||||
|
|
||||||
async like(userId: string, dto: ToggleLikeDto) {
|
async like(userId: string, dto: ToggleLikeDto) {
|
||||||
await this.assertTargetExists(dto);
|
await this.assertTargetExists(dto);
|
||||||
|
await this.assertCanLike(userId, dto);
|
||||||
const notificationContext = await this.resolveNotificationContext(dto);
|
const notificationContext = await this.resolveNotificationContext(dto);
|
||||||
await this.assertNoBlockBetween(userId, notificationContext.recipientId);
|
|
||||||
const reactionType = dto.reactionType ?? ReactionType.LIKE;
|
const reactionType = dto.reactionType ?? ReactionType.LIKE;
|
||||||
|
|
||||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
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.postsRepository.incrementLikesCount(dto.targetId, 1);
|
||||||
}
|
}
|
||||||
await this.feedVersionService.bumpGlobalVersion();
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
if (
|
if (notificationContext.recipientId && notificationContext.recipientId !== userId) {
|
||||||
notificationContext.recipientId &&
|
|
||||||
notificationContext.recipientId !== userId &&
|
|
||||||
!(await this.blocksService.hasBlockBetween(userId, notificationContext.recipientId))
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await this.notificationsService.createLikeNotification(
|
await this.notificationsService.createLikeNotification(
|
||||||
userId,
|
userId,
|
||||||
@@ -123,6 +119,18 @@ export class LikesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertCanLike(userId: string, dto: ToggleLikeDto): Promise<void> {
|
||||||
|
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<boolean> {
|
private async targetExists(dto: ToggleLikeDto): Promise<boolean> {
|
||||||
if (dto.targetType === 'post') {
|
if (dto.targetType === 'post') {
|
||||||
const post = await this.postsRepository.findById(dto.targetId);
|
const post = await this.postsRepository.findById(dto.targetId);
|
||||||
@@ -151,13 +159,14 @@ export class LikesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise<void> {
|
private async resolveTargetOwnerId(dto: ToggleLikeDto): Promise<string> {
|
||||||
if (!targetUserId || actorId === targetUserId) {
|
if (dto.targetType === 'post') {
|
||||||
return;
|
const post = await this.postsRepository.findById(dto.targetId);
|
||||||
}
|
return this.extractEntityId(post?.authorId);
|
||||||
if (await this.blocksService.hasBlockBetween(actorId, targetUserId)) {
|
|
||||||
throw new NotFoundException('Target not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||||
|
return comment?.authorId?.toString?.() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractEntityId(value: unknown): string {
|
private extractEntityId(value: unknown): string {
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ import { MetadataService } from './metadata.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [MetadataController],
|
controllers: [MetadataController],
|
||||||
providers: [MetadataService],
|
providers: [MetadataService],
|
||||||
exports: [MetadataService],
|
|
||||||
})
|
})
|
||||||
export class MetadataModule {}
|
export class MetadataModule {}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { MetadataService } from './metadata.service';
|
import { MetadataService } from './metadata.service';
|
||||||
|
|
||||||
describe('MetadataService', () => {
|
describe('MetadataService', () => {
|
||||||
it('returns profile dropdown options', () => {
|
it('returns public profile option lists for Flutter', () => {
|
||||||
const service = new MetadataService();
|
const service = new MetadataService();
|
||||||
|
|
||||||
expect(service.getProfileOptions()).toEqual(
|
const result = service.getProfileOptions();
|
||||||
expect.objectContaining({
|
|
||||||
musicRoles: expect.arrayContaining(['instrumentalist', 'teacher']),
|
expect(Object.keys(result).sort()).toEqual(
|
||||||
maqams: expect.arrayContaining(['Hijaz', 'Rast']),
|
['experienceLevels', 'instruments', 'maqams', 'moods', 'musicRoles'].sort(),
|
||||||
instruments: expect.arrayContaining(['Oud', 'Piano']),
|
|
||||||
experienceLevels: expect.arrayContaining(['beginner', 'professional']),
|
|
||||||
moods: expect.arrayContaining(['Tarab', 'Classical']),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(result.musicRoles.length).toBeGreaterThan(0);
|
||||||
|
expect(Object.values(result).flat().every((value) => typeof value === 'string')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { PROFILE_OPTIONS, ProfileOptionsResponse } from './profile-options.const
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
getProfileOptions(): ProfileOptionsResponse {
|
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],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { ExperienceLevel } from '../../common/enums/experience-level.enum';
|
|
||||||
import { MusicRole } from '../../common/enums/music-role.enum';
|
|
||||||
|
|
||||||
export const PROFILE_OPTIONS = {
|
export const PROFILE_OPTIONS = {
|
||||||
musicRoles: [
|
musicRoles: ['عازف', 'مغني', 'ملحن', 'مدرس', 'طالب', 'صانع محتوى'],
|
||||||
MusicRole.INSTRUMENTALIST,
|
maqams: ['حجاز', 'بيات', 'راست', 'كرد', 'صبا', 'نهاوند', 'عجم'],
|
||||||
MusicRole.SINGER,
|
instruments: ['العود', 'القانون', 'الناي', 'الكمان', 'البيانو', 'الجيتار', 'الإيقاع'],
|
||||||
MusicRole.COMPOSER,
|
experienceLevels: ['مبتدئ', 'متوسط', 'متقدم', 'محترف'],
|
||||||
MusicRole.LYRICIST,
|
moods: ['طربي', 'شرقي', 'هادئ', 'حزين', 'حماسي', 'كلاسيكي'],
|
||||||
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'],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ProfileOptionsResponse = typeof PROFILE_OPTIONS;
|
export type ProfileOptionsResponse = {
|
||||||
|
[Key in keyof typeof PROFILE_OPTIONS]: string[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
|
import { Types } from 'mongoose';
|
||||||
import { NotificationUnreadCountQueryDto } from './dto/notification-query.dto';
|
import { NotificationUnreadCountQueryDto } from './dto/notification-query.dto';
|
||||||
import { NotificationsService } from './notifications.service';
|
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 () => {
|
it('recalculates unread count after markAllRead', async () => {
|
||||||
const notificationsRepository = {
|
const notificationsRepository = {
|
||||||
markAllRead: jest.fn().mockResolvedValue(4),
|
markAllRead: jest.fn().mockResolvedValue(4),
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export class NotificationsService {
|
|||||||
recipientId: string,
|
recipientId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
previewText = '',
|
previewText = '',
|
||||||
|
messageId?: string,
|
||||||
) {
|
) {
|
||||||
return this.create({
|
return this.create({
|
||||||
actorId,
|
actorId,
|
||||||
@@ -181,6 +182,11 @@ export class NotificationsService {
|
|||||||
resourceType: 'conversation',
|
resourceType: 'conversation',
|
||||||
deepLink: `/chat/conversations/${conversationId}`,
|
deepLink: `/chat/conversations/${conversationId}`,
|
||||||
previewText,
|
previewText,
|
||||||
|
metadata: {
|
||||||
|
type: 'message',
|
||||||
|
conversationId: String(conversationId),
|
||||||
|
...(messageId ? { messageId: String(messageId) } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export class CreatePostDto {
|
|||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above post media' })
|
@ApiPropertyOptional({ maxLength: 2200, description: 'Text shown above media' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
contentTop?: string;
|
contentTop?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below post media' })
|
@ApiPropertyOptional({ maxLength: 2200, description: 'Text shown below media' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ export class CreateReelDto {
|
|||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above reel media' })
|
@ApiPropertyOptional({ maxLength: 2200, description: 'Text shown above reel media' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
contentTop?: string;
|
contentTop?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below reel media' })
|
@ApiPropertyOptional({ maxLength: 2200, description: 'Text shown below reel media' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator';
|
|
||||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
import { PostType } from '../../../common/enums/post-type.enum';
|
import { PostType } from '../../../common/enums/post-type.enum';
|
||||||
import { PostVisibility } from '../../../common/enums/post-visibility.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 const POST_MEDIA_TYPE_FILTERS = [...Object.values(PostType), 'reel'] as const;
|
||||||
export type PostMediaTypeFilter = (typeof POST_MEDIA_TYPE_FILTERS)[number];
|
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 {
|
export class PostQueryDto extends PaginationQueryDto {
|
||||||
@ApiPropertyOptional({ enum: POST_VISIBILITY_FILTERS })
|
@ApiPropertyOptional({ enum: POST_VISIBILITY_FILTERS })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(POST_VISIBILITY_FILTERS)
|
@IsIn(POST_VISIBILITY_FILTERS)
|
||||||
visibility?: PostVisibilityFilter;
|
visibility?: PostVisibilityFilter;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: PostType })
|
@ApiPropertyOptional({ enum: POST_MEDIA_TYPE_FILTERS })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(normalizePostTypeFilter)
|
@IsIn(POST_MEDIA_TYPE_FILTERS)
|
||||||
@IsEnum(PostType)
|
postType?: PostMediaTypeFilter;
|
||||||
postType?: PostType;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ enum: POST_MEDIA_TYPE_FILTERS })
|
||||||
enum: POST_MEDIA_TYPE_FILTERS,
|
|
||||||
description: 'Optional alias for postType. The reel value maps to video posts.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(POST_MEDIA_TYPE_FILTERS)
|
@IsIn(POST_MEDIA_TYPE_FILTERS)
|
||||||
mediaType?: PostMediaTypeFilter;
|
mediaType?: PostMediaTypeFilter;
|
||||||
@@ -59,6 +49,6 @@ export class PostQueryDto extends PaginationQueryDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' })
|
@ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(POST_SORT_FIELDS)
|
@IsIn(POST_SORT_FIELDS)
|
||||||
sortBy?: PostSortField;
|
sortBy?: PostSortField;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export class UpdatePostDto {
|
|||||||
@Length(1, 2200)
|
@Length(1, 2200)
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above post media' })
|
@ApiPropertyOptional({ maxLength: 2200, description: 'Text shown above media' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
contentTop?: string;
|
contentTop?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below post media' })
|
@ApiPropertyOptional({ maxLength: 2200, description: 'Text shown below media' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 2200)
|
@Length(0, 2200)
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export class PostsController {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
content: { type: 'string', example: 'First post #music' },
|
content: { type: 'string', example: 'First post #music' },
|
||||||
contentTop: { type: 'string', example: 'Before the performance #oud' },
|
contentTop: { type: 'string', example: 'Text above media' },
|
||||||
contentBottom: { type: 'string', example: 'Full session caption' },
|
contentBottom: { type: 'string', example: 'Text below media' },
|
||||||
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
imageCaptions: { type: 'array', items: { type: 'string' } },
|
imageCaptions: { type: 'array', items: { type: 'string' } },
|
||||||
@@ -145,8 +145,8 @@ export class PostsController {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
content: { type: 'string', example: 'New reel from oud session #reel' },
|
content: { type: 'string', example: 'New reel from oud session #reel' },
|
||||||
contentTop: { type: 'string', example: 'Live from the studio' },
|
contentTop: { type: 'string', example: 'Text above reel' },
|
||||||
contentBottom: { type: 'string', example: 'New reel from oud session #reel' },
|
contentBottom: { type: 'string', example: 'Text below reel' },
|
||||||
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||||
videoUrl: { type: 'string', example: 'https://cdn.example.com/reel.mp4' },
|
videoUrl: { type: 'string', example: 'https://cdn.example.com/reel.mp4' },
|
||||||
durationSeconds: { type: 'number', example: 42 },
|
durationSeconds: { type: 'number', example: 42 },
|
||||||
@@ -203,8 +203,8 @@ export class PostsController {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
content: { type: 'string', example: 'Updated content' },
|
content: { type: 'string', example: 'Updated content' },
|
||||||
contentTop: { type: 'string', example: 'Updated top text' },
|
contentTop: { type: 'string', example: 'Updated text above media' },
|
||||||
contentBottom: { type: 'string', example: 'Updated bottom text' },
|
contentBottom: { type: 'string', example: 'Updated text below media' },
|
||||||
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
imageCaptions: { type: 'array', items: { type: 'string' } },
|
imageCaptions: { type: 'array', items: { type: 'string' } },
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
|
import { PostType } from '../../common/enums/post-type.enum';
|
||||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||||
import { PostSchema } from './schemas/post.schema';
|
import { PostSchema } from './schemas/post.schema';
|
||||||
import { PostsService } from './posts.service';
|
import { PostsService } from './posts.service';
|
||||||
|
|
||||||
const createService = () => {
|
const createService = () => {
|
||||||
const postsRepository = {
|
const postsRepository = {
|
||||||
create: jest.fn((authorId: string, payload: Record<string, any>) =>
|
|
||||||
Promise.resolve({
|
|
||||||
id: new Types.ObjectId().toString(),
|
|
||||||
authorId: new Types.ObjectId(authorId),
|
|
||||||
...payload,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
updateById: jest.fn(),
|
updateById: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
incrementShareCount: jest.fn().mockResolvedValue(undefined),
|
incrementShareCount: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const connection = {
|
const connection = {
|
||||||
@@ -26,14 +21,14 @@ const createService = () => {
|
|||||||
insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }),
|
insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }),
|
||||||
};
|
};
|
||||||
const usersRepository = {
|
const usersRepository = {
|
||||||
incrementPostsCount: jest.fn().mockResolvedValue(undefined),
|
|
||||||
findByUsernames: jest.fn().mockResolvedValue([]),
|
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
id: new Types.ObjectId().toString(),
|
id: new Types.ObjectId().toString(),
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
toObject: () => ({ _id: new Types.ObjectId(), username: 'viewer', name: 'Viewer' }),
|
toObject: () => ({ _id: new Types.ObjectId(), username: 'viewer', name: 'Viewer' }),
|
||||||
}),
|
}),
|
||||||
|
findByUsernames: jest.fn().mockResolvedValue([]),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
incrementPostsCount: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const notificationsService = {
|
const notificationsService = {
|
||||||
createShareNotification: jest.fn().mockResolvedValue(undefined),
|
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 userId = new Types.ObjectId().toString();
|
||||||
const { service, postsRepository } = createService();
|
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(postsRepository.findMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
isArchived: true,
|
isArchived: true,
|
||||||
postType: 'image',
|
postType: PostType.VIDEO,
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
20,
|
20,
|
||||||
expect.any(Object),
|
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 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({
|
expect.objectContaining({
|
||||||
isArchived: true,
|
content: '',
|
||||||
postType: 'video',
|
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', () => {
|
describe('Post schema response aliases', () => {
|
||||||
it('returns isPinned as a stable alias for pinnedToProfile', () => {
|
it('returns isPinned as a stable alias for pinnedToProfile', () => {
|
||||||
const transform = PostSchema.get('toObject')?.transform as (
|
const transform = PostSchema.get('toObject')?.transform as (
|
||||||
@@ -377,23 +310,4 @@ describe('Post schema response aliases', () => {
|
|||||||
expect(ret.pinnedToProfile).toBe(true);
|
expect(ret.pinnedToProfile).toBe(true);
|
||||||
expect(ret.isPinned).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<string, any>,
|
|
||||||
) => Record<string, any>;
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,12 +178,10 @@ export class PostsService {
|
|||||||
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
|
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
|
||||||
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
|
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
|
||||||
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
||||||
|
const finalContent = dto.content?.trim() ?? '';
|
||||||
const finalContentTop = dto.contentTop?.trim() ?? '';
|
const finalContentTop = dto.contentTop?.trim() ?? '';
|
||||||
const finalContentBottom = dto.contentBottom?.trim() ?? '';
|
const finalContentBottom = dto.contentBottom?.trim() ?? '';
|
||||||
const finalLegacyContent = dto.content?.trim() ?? '';
|
const combinedText = this.combinePostText(finalContent, finalContentTop, finalContentBottom);
|
||||||
const finalContent =
|
|
||||||
typeof dto.contentBottom === 'string' ? finalContentBottom : finalLegacyContent;
|
|
||||||
const finalText = this.combinePostText(finalLegacyContent, finalContentTop, finalContentBottom);
|
|
||||||
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
|
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
|
||||||
const collaboratorIds = await this.normalizeUserIdList(
|
const collaboratorIds = await this.normalizeUserIdList(
|
||||||
dto.collaboratorIds,
|
dto.collaboratorIds,
|
||||||
@@ -195,20 +193,20 @@ export class PostsService {
|
|||||||
const mentionResolution = await this.resolveMentionTargets(
|
const mentionResolution = await this.resolveMentionTargets(
|
||||||
dto.mentionUsernames,
|
dto.mentionUsernames,
|
||||||
dto.mentionedUserIds,
|
dto.mentionedUserIds,
|
||||||
finalText,
|
combinedText,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
const { location, latitude, longitude } = this.normalizeLocation(dto);
|
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');
|
throw new BadRequestException('Post must contain caption or media');
|
||||||
}
|
}
|
||||||
|
|
||||||
const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl);
|
const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl);
|
||||||
const hashtags = this.extractHashtags(finalText);
|
const hashtags = this.extractHashtags(combinedText);
|
||||||
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
|
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
|
||||||
audioSourceBuffer: audioFile?.buffer,
|
audioSourceBuffer: audioFile?.buffer,
|
||||||
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
||||||
waveformSeed: finalAudioUrl || finalText || `${userId}:${Date.now()}`,
|
waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
|
||||||
thumbnailUrl: uploadedThumbnailUrl,
|
thumbnailUrl: uploadedThumbnailUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +266,7 @@ export class PostsService {
|
|||||||
userId,
|
userId,
|
||||||
post.id,
|
post.id,
|
||||||
mentionResolution.mentionedUsers,
|
mentionResolution.mentionedUsers,
|
||||||
finalText,
|
combinedText,
|
||||||
);
|
);
|
||||||
return (await this.postsRepository.findById(post.id)) ?? post;
|
return (await this.postsRepository.findById(post.id)) ?? post;
|
||||||
}
|
}
|
||||||
@@ -387,16 +385,12 @@ export class PostsService {
|
|||||||
? null
|
? null
|
||||||
: existingThumbnailVariants;
|
: existingThumbnailVariants;
|
||||||
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl);
|
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl);
|
||||||
|
const nextContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? '');
|
||||||
const nextContentTop =
|
const nextContentTop =
|
||||||
typeof dto.contentTop === 'string' ? dto.contentTop.trim() : (post.contentTop ?? '');
|
typeof dto.contentTop === 'string' ? dto.contentTop.trim() : ((post as any).contentTop ?? '');
|
||||||
const nextContentBottom =
|
const nextContentBottom =
|
||||||
typeof dto.contentBottom === 'string' ? dto.contentBottom.trim() : (post.contentBottom ?? '');
|
typeof dto.contentBottom === 'string' ? dto.contentBottom.trim() : ((post as any).contentBottom ?? '');
|
||||||
const nextLegacyContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? '');
|
const combinedText = this.combinePostText(nextContent, nextContentTop, nextContentBottom);
|
||||||
const nextContent =
|
|
||||||
typeof dto.contentBottom === 'string'
|
|
||||||
? nextContentBottom
|
|
||||||
: nextLegacyContent;
|
|
||||||
const nextText = this.combinePostText(nextLegacyContent, nextContentTop, nextContentBottom);
|
|
||||||
const nextTaggedUserIds =
|
const nextTaggedUserIds =
|
||||||
typeof dto.taggedUserIds !== 'undefined'
|
typeof dto.taggedUserIds !== 'undefined'
|
||||||
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId)
|
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId)
|
||||||
@@ -426,7 +420,7 @@ export class PostsService {
|
|||||||
typeof dto.mentionUsernames !== 'undefined' ||
|
typeof dto.mentionUsernames !== 'undefined' ||
|
||||||
typeof dto.mentionedUserIds !== 'undefined';
|
typeof dto.mentionedUserIds !== 'undefined';
|
||||||
const mentionResolution = shouldRecomputeMentions
|
const mentionResolution = shouldRecomputeMentions
|
||||||
? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, nextText, userId)
|
? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, combinedText, userId)
|
||||||
: {
|
: {
|
||||||
mentionUsernames: previousMentionUsernames,
|
mentionUsernames: previousMentionUsernames,
|
||||||
mentionedUserIds: previousMentionedUserIds.map((id) => new Types.ObjectId(id)),
|
mentionedUserIds: previousMentionedUserIds.map((id) => new Types.ObjectId(id)),
|
||||||
@@ -441,7 +435,7 @@ export class PostsService {
|
|||||||
latitude: post.latitude ?? null,
|
latitude: post.latitude ?? null,
|
||||||
longitude: post.longitude ?? 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');
|
throw new BadRequestException('Post must contain caption or media');
|
||||||
}
|
}
|
||||||
const mediaMetadata = this.normalizeMediaMetadata(
|
const mediaMetadata = this.normalizeMediaMetadata(
|
||||||
@@ -460,7 +454,7 @@ export class PostsService {
|
|||||||
{
|
{
|
||||||
audioSourceBuffer: audioFile?.buffer,
|
audioSourceBuffer: audioFile?.buffer,
|
||||||
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
||||||
waveformSeed: nextAudioUrl || nextText || post.id,
|
waveformSeed: nextAudioUrl || combinedText || post.id,
|
||||||
thumbnailUrl: uploadedThumbnailUrl,
|
thumbnailUrl: uploadedThumbnailUrl,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -492,10 +486,10 @@ export class PostsService {
|
|||||||
typeof dto.contentTop === 'string' ||
|
typeof dto.contentTop === 'string' ||
|
||||||
typeof dto.contentBottom === 'string'
|
typeof dto.contentBottom === 'string'
|
||||||
) {
|
) {
|
||||||
payload.hashtags = this.extractHashtags(nextText);
|
payload.hashtags = this.extractHashtags(combinedText);
|
||||||
}
|
}
|
||||||
if (hasImageUpdate) {
|
if (hasImageUpdate) {
|
||||||
payload.hashtags = this.extractHashtags(nextText);
|
payload.hashtags = this.extractHashtags(combinedText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVideoUpdate && !hasAudioUpdate) {
|
if (hasVideoUpdate && !hasAudioUpdate) {
|
||||||
@@ -603,7 +597,7 @@ export class PostsService {
|
|||||||
const nextMentionedUsers = mentionResolution.mentionedUsers.filter(
|
const nextMentionedUsers = mentionResolution.mentionedUsers.filter(
|
||||||
(mentionedUser) => !previousMentionSet.has(mentionedUser.username),
|
(mentionedUser) => !previousMentionSet.has(mentionedUser.username),
|
||||||
);
|
);
|
||||||
await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, nextText);
|
await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, combinedText);
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -688,12 +682,12 @@ export class PostsService {
|
|||||||
if (query.visibility && !archivedOnly) {
|
if (query.visibility && !archivedOnly) {
|
||||||
filter.visibility = query.visibility;
|
filter.visibility = query.visibility;
|
||||||
}
|
}
|
||||||
const resolvedPostType = this.resolvePostTypeFilter(query);
|
const postTypeFilter = this.resolvePostTypeFilter(query.mediaType ?? query.postType);
|
||||||
if (resolvedPostType) {
|
if (postTypeFilter) {
|
||||||
filter.postType = resolvedPostType;
|
filter.postType = postTypeFilter;
|
||||||
}
|
}
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
filter.$or = this.buildTextSearchFilter(query.q);
|
filter.content = { $regex: query.q.trim(), $options: 'i' };
|
||||||
}
|
}
|
||||||
if (query.hashtag) {
|
if (query.hashtag) {
|
||||||
filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase();
|
filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase();
|
||||||
@@ -721,21 +715,17 @@ export class PostsService {
|
|||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const filter: Record<string, unknown> = {};
|
const filter: Record<string, unknown> = {};
|
||||||
|
|
||||||
const archivedOnly = query.visibility === 'archived';
|
if (query.visibility) {
|
||||||
if (archivedOnly) {
|
|
||||||
filter.isArchived = true;
|
|
||||||
} else if (query.visibility) {
|
|
||||||
filter.visibility = query.visibility;
|
filter.visibility = query.visibility;
|
||||||
}
|
}
|
||||||
const resolvedPostType = this.resolvePostTypeFilter(query);
|
if (query.postType) {
|
||||||
if (resolvedPostType) {
|
filter.postType = query.postType;
|
||||||
filter.postType = resolvedPostType;
|
|
||||||
}
|
}
|
||||||
if (query.authorId) {
|
if (query.authorId) {
|
||||||
filter.authorId = new Types.ObjectId(query.authorId);
|
filter.authorId = new Types.ObjectId(query.authorId);
|
||||||
}
|
}
|
||||||
if (query.q?.trim()) {
|
if (query.q?.trim()) {
|
||||||
filter.$or = this.buildTextSearchFilter(query.q);
|
filter.content = { $regex: query.q.trim(), $options: 'i' };
|
||||||
}
|
}
|
||||||
if (query.hashtag?.trim()) {
|
if (query.hashtag?.trim()) {
|
||||||
filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase();
|
filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase();
|
||||||
@@ -810,7 +800,7 @@ export class PostsService {
|
|||||||
filter.authorId = new Types.ObjectId(query.authorId);
|
filter.authorId = new Types.ObjectId(query.authorId);
|
||||||
}
|
}
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
filter.$or = this.buildTextSearchFilter(query.q);
|
filter.content = { $regex: query.q.trim(), $options: 'i' };
|
||||||
}
|
}
|
||||||
const direction = resolveMongoSortDirection(query.sortOrder);
|
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||||
const sortField = query.sortBy ?? 'createdAt';
|
const sortField = query.sortBy ?? 'createdAt';
|
||||||
@@ -1160,14 +1150,11 @@ export class PostsService {
|
|||||||
return PostType.TEXT;
|
return PostType.TEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePostTypeFilter(query: Pick<PostQueryDto, 'postType' | 'mediaType'>): PostType | undefined {
|
private resolvePostTypeFilter(input?: string): PostType | null {
|
||||||
if (query.postType) {
|
if (!input) {
|
||||||
return query.postType;
|
return null;
|
||||||
}
|
}
|
||||||
if (query.mediaType === 'reel') {
|
return input === 'reel' ? PostType.VIDEO : (input as PostType);
|
||||||
return PostType.VIDEO;
|
|
||||||
}
|
|
||||||
return query.mediaType as PostType | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMediaMetadata(
|
private normalizeMediaMetadata(
|
||||||
@@ -1271,19 +1258,11 @@ export class PostsService {
|
|||||||
return Array.from(new Set(normalized)).slice(0, 30);
|
return Array.from(new Set(normalized)).slice(0, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
private combinePostText(...values: Array<string | undefined | null>): string {
|
private combinePostText(content = '', contentTop = '', contentBottom = ''): string {
|
||||||
return Array.from(
|
return [contentTop, contentBottom, content]
|
||||||
new Set(
|
.map((value) => value.trim())
|
||||||
values
|
.filter(Boolean)
|
||||||
.map((value) => value?.trim() ?? '')
|
.join('\n');
|
||||||
.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 normalizeMentionUsernames(input: string[] = []): string[] {
|
private normalizeMentionUsernames(input: string[] = []): string[] {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
|
||||||
import { BlocksModule } from '../blocks/blocks.module';
|
import { BlocksModule } from '../blocks/blocks.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { PostsModule } from '../posts/posts.module';
|
import { PostsModule } from '../posts/posts.module';
|
||||||
import { Save, SaveSchema } from './schemas/save.schema';
|
import { Save, SaveSchema } from './schemas/save.schema';
|
||||||
import { SavesController } from './saves.controller';
|
import { SavesController } from './saves.controller';
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ describe('SavesService', () => {
|
|||||||
const postsRepository = {
|
const postsRepository = {
|
||||||
findById: jest.fn().mockResolvedValue(null),
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
};
|
};
|
||||||
|
const blocksRepository = {
|
||||||
|
findAnyBetween: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new SavesService(
|
const service = new SavesService(
|
||||||
savesRepository as any,
|
savesRepository as any,
|
||||||
postsRepository as any,
|
postsRepository as any,
|
||||||
{ bumpGlobalVersion: jest.fn() } as any,
|
{ bumpGlobalVersion: jest.fn() } as any,
|
||||||
{ createSaveNotification: jest.fn() } as any,
|
{ createSaveNotification: jest.fn() } as any,
|
||||||
{ hasBlockBetween: jest.fn().mockResolvedValue(false) } as any,
|
blocksRepository as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -25,4 +28,32 @@ describe('SavesService', () => {
|
|||||||
});
|
});
|
||||||
expect(savesRepository.findOne).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Types } from 'mongoose';
|
import { Types } from 'mongoose';
|
||||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||||
|
import { BlocksRepository } from '../blocks/blocks.repository';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { BlocksService } from '../blocks/blocks.service';
|
|
||||||
import { PostsRepository } from '../posts/posts.repository';
|
import { PostsRepository } from '../posts/posts.repository';
|
||||||
import { ToggleSaveDto } from './dto/toggle-save.dto';
|
import { ToggleSaveDto } from './dto/toggle-save.dto';
|
||||||
import { SavesRepository } from './saves.repository';
|
import { SavesRepository } from './saves.repository';
|
||||||
@@ -19,7 +19,7 @@ export class SavesService {
|
|||||||
private readonly postsRepository: PostsRepository,
|
private readonly postsRepository: PostsRepository,
|
||||||
private readonly feedVersionService: FeedVersionService,
|
private readonly feedVersionService: FeedVersionService,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
private readonly blocksService: BlocksService,
|
private readonly blocksRepository: BlocksRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async toggle(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> {
|
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 }> {
|
async save(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> {
|
||||||
const post = await this.getPostOrThrow(dto.postId);
|
const post = await this.getPostOrThrow(dto.postId);
|
||||||
const recipientId = this.extractEntityId(post.authorId);
|
await this.assertCanSave(userId, post);
|
||||||
await this.assertNoBlockBetween(userId, recipientId);
|
|
||||||
|
|
||||||
const existing = await this.savesRepository.findOne(userId, dto.postId);
|
const existing = await this.savesRepository.findOne(userId, dto.postId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -40,11 +39,8 @@ export class SavesService {
|
|||||||
await this.savesRepository.create(userId, dto.postId);
|
await this.savesRepository.create(userId, dto.postId);
|
||||||
await this.postsRepository.incrementSavesCount(dto.postId, 1);
|
await this.postsRepository.incrementSavesCount(dto.postId, 1);
|
||||||
await this.feedVersionService.bumpGlobalVersion();
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
if (
|
const recipientId = this.extractEntityId(post.authorId);
|
||||||
recipientId &&
|
if (recipientId && recipientId !== userId) {
|
||||||
recipientId !== userId &&
|
|
||||||
!(await this.blocksService.hasBlockBetween(userId, recipientId))
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await this.notificationsService.createSaveNotification(userId, recipientId, dto.postId, {
|
await this.notificationsService.createSaveNotification(userId, recipientId, dto.postId, {
|
||||||
resourceType: 'post',
|
resourceType: 'post',
|
||||||
@@ -123,12 +119,15 @@ export class SavesService {
|
|||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise<void> {
|
private async assertCanSave(userId: string, post: any): Promise<void> {
|
||||||
if (!targetUserId || actorId === targetUserId) {
|
const authorId = this.extractEntityId(post.authorId);
|
||||||
|
if (!authorId || authorId === userId) {
|
||||||
return;
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,4 +127,10 @@ export class CreateUserDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
favoriteMaqamat?: string[];
|
favoriteMaqamat?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'طربي', maxLength: 80 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 80)
|
||||||
|
preferredMood?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class MusicSetupDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
favoriteMaqamat?: string[];
|
favoriteMaqamat?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Tarab', maxLength: 80 })
|
@ApiPropertyOptional({ example: 'طربي', maxLength: 80 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 80)
|
@Length(0, 80)
|
||||||
|
|||||||
@@ -25,18 +25,18 @@ export class ProfileSetupDto {
|
|||||||
@Length(0, 150)
|
@Length(0, 150)
|
||||||
bio?: string;
|
bio?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Tarab', maxLength: 80 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Length(0, 80)
|
|
||||||
preferredMood?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' })
|
@ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 120)
|
@Length(0, 120)
|
||||||
location?: string;
|
location?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'طربي', maxLength: 80 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 80)
|
||||||
|
preferredMood?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 })
|
@ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 })
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? Number.parseFloat(value) : value))
|
@Transform(({ value }) => (typeof value === 'string' ? Number.parseFloat(value) : value))
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class UpdateUserDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
favoriteMaqamat?: string[];
|
favoriteMaqamat?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Tarab', maxLength: 80 })
|
@ApiPropertyOptional({ example: 'طربي', maxLength: 80 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(0, 80)
|
@Length(0, 80)
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ const createService = (options: {
|
|||||||
followingCount: 4,
|
followingCount: 4,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
preferredMood: '',
|
||||||
|
get(field: string) {
|
||||||
|
return (this as Record<string, unknown>)[field];
|
||||||
|
},
|
||||||
toObject() {
|
toObject() {
|
||||||
return {
|
return {
|
||||||
_id: userId,
|
_id: userId,
|
||||||
@@ -44,6 +48,7 @@ const createService = (options: {
|
|||||||
followersCount: this.followersCount,
|
followersCount: this.followersCount,
|
||||||
followingCount: this.followingCount,
|
followingCount: this.followingCount,
|
||||||
isVerified: this.isVerified,
|
isVerified: this.isVerified,
|
||||||
|
preferredMood: this.preferredMood,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -103,7 +108,6 @@ const createService = (options: {
|
|||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
...user,
|
...user,
|
||||||
...payload,
|
...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', () => {
|
describe('UsersService artist dashboard', () => {
|
||||||
it('returns zeros and safe empty arrays for an authenticated user with no posts', async () => {
|
it('returns zeros and safe empty arrays for an authenticated user with no posts', async () => {
|
||||||
const { service, userId } = createService();
|
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' }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export class UsersService {
|
|||||||
musicGenres: dto.musicGenres ?? [],
|
musicGenres: dto.musicGenres ?? [],
|
||||||
favoriteInstruments: dto.favoriteInstruments ?? [],
|
favoriteInstruments: dto.favoriteInstruments ?? [],
|
||||||
favoriteMaqamat: dto.favoriteMaqamat ?? [],
|
favoriteMaqamat: dto.favoriteMaqamat ?? [],
|
||||||
|
preferredMood: dto.preferredMood ?? '',
|
||||||
role: dto.role ?? UserRole.USER,
|
role: dto.role ?? UserRole.USER,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
disabledReason: '',
|
disabledReason: '',
|
||||||
@@ -331,7 +332,7 @@ export class UsersService {
|
|||||||
coverImageFile?: UploadedImageFile,
|
coverImageFile?: UploadedImageFile,
|
||||||
): Promise<UserDocument> {
|
): Promise<UserDocument> {
|
||||||
const currentUser = await this.findByIdOrFail(userId);
|
const currentUser = await this.findByIdOrFail(userId);
|
||||||
const payload = await this.prepareManagedUserUpdatePayload(userId, dto);
|
const payload: Record<string, unknown> = { ...dto };
|
||||||
const uploadedImageUrls = await this.attachUploadedProfileImages(
|
const uploadedImageUrls = await this.attachUploadedProfileImages(
|
||||||
payload,
|
payload,
|
||||||
avatarFile,
|
avatarFile,
|
||||||
|
|||||||
Reference in New Issue
Block a user