@@ -604,7 +607,27 @@ export default function ContentPage() {
>
{selectedPost ? (
+ {selectedPost.contentTop?.trim() ? (
+
+
+ Top text
+
+
+ {selectedPost.contentTop.trim()}
+
+
+ ) : null}
+ {(selectedPost.contentBottom?.trim() || selectedPost.content?.trim()) ? (
+
+
+ Bottom text
+
+
+ {selectedPost.contentBottom?.trim() || selectedPost.content.trim()}
+
+
+ ) : null}
Post metadata
diff --git a/oudelaa_dashboard/app/(dashboard)/users/page.tsx b/oudelaa_dashboard/app/(dashboard)/users/page.tsx
index fd27946..73ce6f6 100644
--- a/oudelaa_dashboard/app/(dashboard)/users/page.tsx
+++ b/oudelaa_dashboard/app/(dashboard)/users/page.tsx
@@ -73,6 +73,13 @@ function buildEditPayload(user: ApiUser): Partial {
stageName: user.stageName ?? "",
bio: user.bio ?? "",
location: user.location ?? "",
+ avatar: user.avatar ?? "",
+ coverImage: user.coverImage ?? "",
+ musicRoles: user.musicRoles ?? [],
+ favoriteMaqamat: user.favoriteMaqamat ?? [],
+ preferredMood: user.preferredMood ?? "",
+ experienceLevel: user.experienceLevel ?? "",
+ favoriteInstruments: user.favoriteInstruments ?? [],
isPrivate: user.isPrivate ?? false,
isVerified: user.isVerified ?? false,
};
@@ -82,6 +89,20 @@ function normalizeComparableString(value: string | null | undefined) {
return (value ?? "").trim();
}
+function normalizeStringList(value: unknown): string[] {
+ if (Array.isArray(value)) {
+ return value.map((item) => String(item).trim()).filter(Boolean);
+ }
+ if (typeof value === "string") {
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
+ }
+ return [];
+}
+
+function areStringListsEqual(left: string[], right: string[]) {
+ return left.length === right.length && left.every((item, index) => item === right[index]);
+}
+
function buildUserUpdatePayload(
selectedUser: ApiUser,
editPayload: Partial,
@@ -145,6 +166,58 @@ function buildUserUpdatePayload(
payload.location = nextLocation;
}
+ const nextAvatar = normalizeComparableString(
+ typeof editPayload.avatar === "string" ? editPayload.avatar : selectedUser.avatar,
+ );
+ const currentAvatar = normalizeComparableString(selectedUser.avatar);
+ if (nextAvatar !== currentAvatar) {
+ payload.avatar = nextAvatar;
+ }
+
+ const nextCoverImage = normalizeComparableString(
+ typeof editPayload.coverImage === "string" ? editPayload.coverImage : selectedUser.coverImage,
+ );
+ const currentCoverImage = normalizeComparableString(selectedUser.coverImage);
+ if (nextCoverImage !== currentCoverImage) {
+ payload.coverImage = nextCoverImage;
+ }
+
+ const nextMusicRoles = normalizeStringList(editPayload.musicRoles ?? selectedUser.musicRoles);
+ const currentMusicRoles = normalizeStringList(selectedUser.musicRoles);
+ if (!areStringListsEqual(nextMusicRoles, currentMusicRoles)) {
+ payload.musicRoles = nextMusicRoles;
+ }
+
+ const nextFavoriteMaqamat = normalizeStringList(editPayload.favoriteMaqamat ?? selectedUser.favoriteMaqamat);
+ const currentFavoriteMaqamat = normalizeStringList(selectedUser.favoriteMaqamat);
+ if (!areStringListsEqual(nextFavoriteMaqamat, currentFavoriteMaqamat)) {
+ payload.favoriteMaqamat = nextFavoriteMaqamat;
+ }
+
+ const nextFavoriteInstruments = normalizeStringList(
+ editPayload.favoriteInstruments ?? selectedUser.favoriteInstruments,
+ );
+ const currentFavoriteInstruments = normalizeStringList(selectedUser.favoriteInstruments);
+ if (!areStringListsEqual(nextFavoriteInstruments, currentFavoriteInstruments)) {
+ payload.favoriteInstruments = nextFavoriteInstruments;
+ }
+
+ const nextPreferredMood = normalizeComparableString(
+ typeof editPayload.preferredMood === "string" ? editPayload.preferredMood : selectedUser.preferredMood,
+ );
+ const currentPreferredMood = normalizeComparableString(selectedUser.preferredMood);
+ if (nextPreferredMood !== currentPreferredMood) {
+ payload.preferredMood = nextPreferredMood;
+ }
+
+ const nextExperienceLevel = normalizeComparableString(
+ typeof editPayload.experienceLevel === "string" ? editPayload.experienceLevel : selectedUser.experienceLevel,
+ );
+ const currentExperienceLevel = normalizeComparableString(selectedUser.experienceLevel);
+ if (nextExperienceLevel !== currentExperienceLevel) {
+ payload.experienceLevel = nextExperienceLevel;
+ }
+
const nextIsPrivate = Boolean(editPayload.isPrivate ?? selectedUser.isPrivate ?? false);
const currentIsPrivate = Boolean(selectedUser.isPrivate ?? false);
if (nextIsPrivate !== currentIsPrivate) {
@@ -167,13 +240,17 @@ function FieldRow({
valueClassName,
}: {
label: string;
- value: string | number | boolean | null | undefined;
+ value: string | string[] | number | boolean | null | undefined;
valueDir?: "auto" | "rtl" | "ltr";
valueClassName?: string;
}) {
const normalized =
value === null || value === undefined || value === ""
? "-"
+ : Array.isArray(value)
+ ? value.length
+ ? value.join(", ")
+ : "-"
: typeof value === "boolean"
? value
? "نعم"
@@ -786,6 +863,67 @@ export default function UsersPage() {
}
/>
+
+
+ setEditPayload((prev) => ({ ...prev, avatar: event.target.value }))
+ }
+ />
+
+ setEditPayload((prev) => ({ ...prev, coverImage: event.target.value }))
+ }
+ />
+
+
+
+ setEditPayload((prev) => ({ ...prev, musicRoles: normalizeStringList(event.target.value) }))
+ }
+ />
+
+ setEditPayload((prev) => ({ ...prev, experienceLevel: event.target.value }))
+ }
+ />
+
+
+
+ setEditPayload((prev) => ({
+ ...prev,
+ favoriteMaqamat: normalizeStringList(event.target.value),
+ }))
+ }
+ />
+
+ setEditPayload((prev) => ({
+ ...prev,
+ favoriteInstruments: normalizeStringList(event.target.value),
+ }))
+ }
+ />
+
+
+ setEditPayload((prev) => ({ ...prev, preferredMood: event.target.value }))
+ }
+ />
+
+
+
+
+
+
+
diff --git a/oudelaa_dashboard/components/dashboard/post-preview-card.tsx b/oudelaa_dashboard/components/dashboard/post-preview-card.tsx
index 51f6be8..8d69c48 100644
--- a/oudelaa_dashboard/components/dashboard/post-preview-card.tsx
+++ b/oudelaa_dashboard/components/dashboard/post-preview-card.tsx
@@ -44,10 +44,18 @@ export function PostPreviewCard({ post }: { post: ApiPost }) {
const showImagePreview = !!media.url && !imageFailed && !(media.kind === "video" && isVideoPlaying);
const showMediaShell = media.kind !== "text";
const showUnavailable = !showImagePreview && !showVideoPreview && !showAudioPreview;
+ const topText = post.contentTop?.trim() ?? "";
+ const bottomText = post.contentBottom?.trim() || post.content?.trim() || "";
return (
+ {topText ? (
+
+ ) : null}
+
{showMediaShell ? (
{showImagePreview ? (
@@ -157,7 +165,7 @@ export function PostPreviewCard({ post }: { post: ApiPost }) {
- {post.content?.trim() || "Media post without caption."}
+ {bottomText || "Media post without caption."}
diff --git a/oudelaa_dashboard/types/api.ts b/oudelaa_dashboard/types/api.ts
index 7032186..5a02140 100644
--- a/oudelaa_dashboard/types/api.ts
+++ b/oudelaa_dashboard/types/api.ts
@@ -56,6 +56,7 @@ export type ApiUser = {
musicGenres?: string[];
favoriteInstruments?: string[];
favoriteMaqamat?: string[];
+ preferredMood?: string;
experienceLevel?: string;
isPrivate?: boolean;
isDisabled?: boolean;
@@ -87,7 +88,21 @@ export type AdminCreatePayload = {
export type AdminUpdatePayload = Partial<
Pick<
ApiUser,
- "name" | "username" | "email" | "stageName" | "bio" | "location" | "isPrivate" | "isVerified"
+ | "name"
+ | "username"
+ | "email"
+ | "stageName"
+ | "bio"
+ | "location"
+ | "avatar"
+ | "coverImage"
+ | "musicRoles"
+ | "favoriteMaqamat"
+ | "preferredMood"
+ | "experienceLevel"
+ | "favoriteInstruments"
+ | "isPrivate"
+ | "isVerified"
>
>;
@@ -130,10 +145,26 @@ export type SuperAdminSessionResponse = {
export type ApiPostType = "text" | "image" | "video" | "audio";
export type ApiPostVisibility = "public" | "followers" | "private";
export type ModerationStatus = "active" | "hidden" | "flagged";
+export type MediaProcessingStatus = "pending" | "processing" | "ready" | "failed";
+
+export type ApiPostMedia = {
+ mediaType?: ApiPostType;
+ displayUrl?: string;
+ thumbnailUrl?: string;
+ preferredPlaybackUrl?: string;
+ hlsUrl?: string;
+ videoUrl?: string;
+ audioUrl?: string;
+ durationSeconds?: number | null;
+ isPlayable?: boolean;
+};
export type ApiPost = {
_id: string;
+ id?: string;
content: string;
+ contentTop?: string;
+ contentBottom?: string;
visibility?: ApiPostVisibility;
postType?: ApiPostType;
imageUrls?: string[];
@@ -151,7 +182,8 @@ export type ApiPost = {
thumbnailUrl?: string;
thumbnailVariants?: Record
;
durationSeconds?: number | null;
- processingStatus?: "pending" | "processing" | "ready" | "failed" | string;
+ processingStatus?: MediaProcessingStatus;
+ media?: ApiPostMedia;
style?: string;
maqam?: string;
rhythmSignature?: string;
@@ -270,20 +302,115 @@ export type DeviceItem = {
updatedAt?: string;
};
-export type CollaborationRequestStatus = "pending" | "approved" | "rejected";
+export type CollaborationRequestStatus = "pending" | "approved" | "rejected" | "cancelled";
+export type CollaborationType = "duet" | "arrangement" | "composition";
+export type CollaborationAttachmentType = "audio" | "demo" | "file";
export type CollaborationRequestItem = {
- _id: string;
- postId: ApiPost | string;
- requesterId: ApiUser | string;
- targetUserId: ApiUser | string;
+ _id?: string;
+ id?: string;
+ postId?: ApiPost | string | null;
+ requesterId?: ApiUser | string;
+ targetUserId?: ApiUser | string;
+ requester?: ApiUser | null;
+ targetUser?: ApiUser | null;
+ post?: ApiPost | null;
status: CollaborationRequestStatus;
+ collaborationType?: CollaborationType | null;
+ message?: string;
+ attachmentUrl?: string;
+ attachmentType?: CollaborationAttachmentType | null;
createdAt?: string;
updatedAt?: string;
};
export type CollaborationRequestsResponse = PaginatedResponse;
+export type CollaborationRequestActionResponse = {
+ approved?: boolean;
+ rejected?: boolean;
+ cancelled?: boolean;
+ request: CollaborationRequestItem;
+};
+
+export type CollaborationRequestDetailResponse = {
+ request: CollaborationRequestItem;
+};
+
+export type SuperAdminCollaborationRequestStatusPayload = {
+ status: Exclude;
+ reason?: string;
+};
+
+export type MediaHealthStatus = "ok" | "warning" | "error";
+
+export type MediaHealthResponse = {
+ status: MediaHealthStatus;
+ storage: {
+ provider?: "local" | "s3";
+ storageProvider?: "local" | "s3";
+ basePath?: string;
+ storageBasePath?: string;
+ publicPath?: string;
+ uploadsPublicPath?: string;
+ publicBaseUrlConfigured?: boolean;
+ publicBaseUrl?: string;
+ storagePublicBaseUrlConfigured?: boolean;
+ storagePublicBaseUrl?: string;
+ isLocalStorage?: boolean;
+ isS3Configured?: boolean;
+ s3Configured?: boolean;
+ uploadPathExists?: boolean;
+ uploadPathReadable?: boolean;
+ uploadPathWritable?: boolean;
+ local?: Record;
+ s3?: {
+ reachable?: boolean;
+ bucket?: string;
+ endpoint?: string;
+ region?: string;
+ forcePathStyle?: boolean;
+ error?: string;
+ [key: string]: unknown;
+ };
+ };
+ processing: {
+ imageProcessingEnabled: boolean;
+ videoProcessingEnabled: boolean;
+ videoHlsGenerationEnabled: boolean;
+ videoThumbnailGenerationEnabled: boolean;
+ maxVideoWidth: number;
+ maxVideoFps: number;
+ videoCrf: number;
+ audioBitrateKbps: number;
+ audioProcessingEnabled: boolean;
+ ffmpegPath?: string;
+ ffmpegAvailable: boolean;
+ ffmpegVersion?: string;
+ ffprobePath?: string;
+ ffprobeAvailable: boolean;
+ ffprobeVersion?: string;
+ ffmpeg?: Record;
+ ffprobe?: Record;
+ };
+ serving: {
+ mediaAccessMode: "direct" | "signed" | string;
+ signedUrlsEnabled: boolean;
+ rangeRequests: boolean;
+ immutableCacheSeconds: number;
+ hlsManifestCacheSeconds: number;
+ s3ImmutableCacheControl?: string;
+ s3HlsManifestCacheControl?: string;
+ };
+ staticServing: {
+ uploadsPublicPath: string;
+ rangeRequestsExpected: boolean;
+ cacheHeadersExpected: boolean;
+ hlsMimeExpected: string;
+ };
+ warnings: string[];
+};
+
export type NotificationType =
| "like"
| "comment"
diff --git a/src/app.module.ts b/src/app.module.ts
index bdd28e9..1e4c361 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -22,6 +22,7 @@ import { FeedModule } from './modules/feed/feed.module';
import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module';
import { MediaModule } from './modules/media/media.module';
+import { MetadataModule } from './modules/metadata/metadata.module';
import { MarketplaceModule } from './modules/marketplace/marketplace.module';
import { MusicWorldModule } from './modules/music-world/music-world.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
@@ -65,6 +66,7 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor
OutboxModule,
ChatModule,
MediaModule,
+ MetadataModule,
MarketplaceModule,
MusicWorldModule,
ReportsModule,
diff --git a/src/common/enums/music-role.enum.ts b/src/common/enums/music-role.enum.ts
index 6d08928..08211c2 100644
--- a/src/common/enums/music-role.enum.ts
+++ b/src/common/enums/music-role.enum.ts
@@ -5,4 +5,7 @@ export enum MusicRole {
LYRICIST = 'lyricist',
PRODUCER = 'producer',
ARRANGER = 'arranger',
+ TEACHER = 'teacher',
+ STUDENT = 'student',
+ CONTENT_CREATOR = 'content_creator',
}
diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts
index 8441fa0..5ea392e 100644
--- a/src/modules/auth/auth.controller.ts
+++ b/src/modules/auth/auth.controller.ts
@@ -9,6 +9,7 @@ import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permi
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { AuthService } from './auth.service';
+import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
import { LoginDto } from './dto/login.dto';
@@ -82,6 +83,14 @@ export class AuthController {
return this.authService.resetPassword(dto);
}
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ @HttpCode(HttpStatus.OK)
+ @Post('change-password')
+ async changePassword(@CurrentUser() user: JwtPayload, @Body() dto: ChangePasswordDto) {
+ return this.authService.changePassword(user.sub, dto);
+ }
+
@HttpCode(HttpStatus.OK)
@Post('send-email-verification')
@Throttle(8, 60_000)
diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts
new file mode 100644
index 0000000..1812529
--- /dev/null
+++ b/src/modules/auth/auth.service.spec.ts
@@ -0,0 +1,74 @@
+import { UnauthorizedException } from '@nestjs/common';
+import { validate } from 'class-validator';
+import { hashValue } from '../../common/utils/hash.util';
+import { AuthService } from './auth.service';
+import { ChangePasswordDto } from './dto/change-password.dto';
+
+const createService = async () => {
+ const userId = '507f1f77bcf86cd799439011';
+ const usersService = {
+ findByIdWithPassword: jest.fn().mockResolvedValue({
+ id: userId,
+ password: await hashValue('OldPassword123', 4),
+ isDisabled: false,
+ }),
+ updatePassword: jest.fn(),
+ };
+ const authRepository = {
+ revokeAllUserTokens: jest.fn(),
+ removeExpiredAndRevoked: jest.fn(),
+ };
+ const configService = {
+ get: jest.fn((key: string) => (key === 'security.bcryptSaltRounds' ? 4 : undefined)),
+ };
+
+ const service = new AuthService(
+ usersService as any,
+ authRepository as any,
+ {} as any,
+ configService as any,
+ {} as any,
+ );
+
+ return { service, userId, usersService, authRepository };
+};
+
+describe('AuthService changePassword', () => {
+ it('changes password, revokes refresh tokens, and returns no password hash', async () => {
+ const { service, userId, usersService, authRepository } = await createService();
+
+ const result = await service.changePassword(userId, {
+ currentPassword: 'OldPassword123',
+ newPassword: 'NewPassword123',
+ });
+
+ expect(usersService.updatePassword).toHaveBeenCalledWith(userId, expect.any(String));
+ expect(authRepository.revokeAllUserTokens).toHaveBeenCalledWith(userId);
+ expect(authRepository.removeExpiredAndRevoked).toHaveBeenCalledWith(userId);
+ expect(result).toEqual({ message: 'Password changed successfully' });
+ expect(result).not.toHaveProperty('password');
+ });
+
+ it('rejects an incorrect current password', async () => {
+ const { service, userId, usersService } = await createService();
+
+ await expect(
+ service.changePassword(userId, {
+ currentPassword: 'WrongPassword123',
+ newPassword: 'NewPassword123',
+ }),
+ ).rejects.toBeInstanceOf(UnauthorizedException);
+ expect(usersService.updatePassword).not.toHaveBeenCalled();
+ });
+
+ it('validates weak new passwords', async () => {
+ const dto = Object.assign(new ChangePasswordDto(), {
+ currentPassword: 'OldPassword123',
+ newPassword: 'weakpass',
+ });
+
+ const errors = await validate(dto);
+
+ expect(errors.some((error) => error.property === 'newPassword')).toBe(true);
+ });
+});
diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts
index 785e2a2..fad5707 100644
--- a/src/modules/auth/auth.service.ts
+++ b/src/modules/auth/auth.service.ts
@@ -17,6 +17,7 @@ import {
} from '../../common/utils/hash.util';
import { EmailService } from '../email/email.service';
import { UsersService } from '../users/users.service';
+import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
import { LoginDto } from './dto/login.dto';
@@ -539,6 +540,29 @@ export class AuthService {
return { message: 'Password reset successfully' };
}
+ async changePassword(userId: string, dto: ChangePasswordDto): Promise<{ message: string }> {
+ const user = await this.usersService.findByIdWithPassword(userId);
+ if (!user || !user.password) {
+ throw new UnauthorizedException('Invalid credentials');
+ }
+ if (user.isDisabled) {
+ throw new ForbiddenException('Account is disabled');
+ }
+
+ const isMatch = await compareHash(dto.currentPassword, user.password);
+ if (!isMatch) {
+ throw new UnauthorizedException('Current password is incorrect');
+ }
+
+ const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true });
+ const passwordHash = await hashValue(dto.newPassword, saltRounds);
+ await this.usersService.updatePassword(userId, passwordHash);
+ await this.authRepository.revokeAllUserTokens(userId);
+ await this.authRepository.removeExpiredAndRevoked(userId);
+
+ return { message: 'Password changed successfully' };
+ }
+
private async generateAndStoreTokenPair(
userId: string,
username: string,
diff --git a/src/modules/auth/dto/change-password.dto.ts b/src/modules/auth/dto/change-password.dto.ts
new file mode 100644
index 0000000..8974984
--- /dev/null
+++ b/src/modules/auth/dto/change-password.dto.ts
@@ -0,0 +1,19 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, Length, Matches } from 'class-validator';
+
+const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
+
+export class ChangePasswordDto {
+ @ApiProperty({ minLength: 8, maxLength: 64 })
+ @IsString()
+ @Length(8, 64)
+ currentPassword!: string;
+
+ @ApiProperty({ minLength: 8, maxLength: 64 })
+ @IsString()
+ @Length(8, 64)
+ @Matches(PASSWORD_PATTERN, {
+ message: 'newPassword must contain uppercase, lowercase, and number characters',
+ })
+ newPassword!: string;
+}
diff --git a/src/modules/blocks/blocks.controller.ts b/src/modules/blocks/blocks.controller.ts
index 1e2e225..9e2252d 100644
--- a/src/modules/blocks/blocks.controller.ts
+++ b/src/modules/blocks/blocks.controller.ts
@@ -1,9 +1,10 @@
-import { Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
+import { Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { BlocksService } from './blocks.service';
+import { BlockListQueryDto } from './dto/block-list-query.dto';
@ApiTags('Blocks')
@ApiBearerAuth()
@@ -12,6 +13,11 @@ import { BlocksService } from './blocks.service';
export class BlocksController {
constructor(private readonly blocksService: BlocksService) {}
+ @Get()
+ async list(@CurrentUser() user: JwtPayload, @Query() query: BlockListQueryDto) {
+ return this.blocksService.listBlockedUsers(user.sub, query);
+ }
+
@Post(':targetUserId')
async block(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.blocksService.block(user.sub, targetUserId);
diff --git a/src/modules/blocks/blocks.repository.ts b/src/modules/blocks/blocks.repository.ts
index e29e9ec..7f9cdc1 100644
--- a/src/modules/blocks/blocks.repository.ts
+++ b/src/modules/blocks/blocks.repository.ts
@@ -1,8 +1,19 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
-import { Model, Types } from 'mongoose';
+import { Model, PipelineStage, Types } from 'mongoose';
import { Block, BlockDocument } from './schemas/block.schema';
+export type BlockedUserRow = {
+ blockedUser: {
+ _id: Types.ObjectId;
+ name?: string;
+ username?: string;
+ avatar?: string;
+ stageName?: string;
+ };
+ createdAt?: Date;
+};
+
@Injectable()
export class BlocksRepository {
constructor(@InjectModel(Block.name) private readonly blockModel: Model) {}
@@ -64,6 +75,46 @@ export class BlocksRepository {
return rows.map((row) => row.blockedId.toString());
}
+ async findBlockedUsers(
+ blockerId: string,
+ skip: number,
+ limit: number,
+ search?: string,
+ ): Promise {
+ return this.blockModel
+ .aggregate([
+ { $match: { blockerId: new Types.ObjectId(blockerId) } },
+ ...this.blockedUserLookupStages(search),
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $project: {
+ createdAt: 1,
+ blockedUser: {
+ _id: '$blockedUser._id',
+ name: '$blockedUser.name',
+ username: '$blockedUser.username',
+ avatar: '$blockedUser.avatar',
+ stageName: '$blockedUser.stageName',
+ },
+ },
+ },
+ ])
+ .exec();
+ }
+
+ async countBlockedUsers(blockerId: string, search?: string): Promise {
+ const rows = await this.blockModel
+ .aggregate<{ total: number }>([
+ { $match: { blockerId: new Types.ObjectId(blockerId) } },
+ ...this.blockedUserLookupStages(search),
+ { $count: 'total' },
+ ])
+ .exec();
+ return rows[0]?.total ?? 0;
+ }
+
async findBlockingOrBlockedIds(userId: string): Promise {
const userObjectId = new Types.ObjectId(userId);
const rows = await this.blockModel
@@ -80,4 +131,40 @@ export class BlocksRepository {
),
);
}
+
+ private blockedUserLookupStages(search?: string): PipelineStage[] {
+ const stages: PipelineStage[] = [
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'blockedId',
+ foreignField: '_id',
+ as: 'blockedUser',
+ },
+ },
+ { $unwind: '$blockedUser' },
+ { $match: { 'blockedUser.isDisabled': { $ne: true } } },
+ ];
+ const searchFilter = this.buildBlockedUserSearch(search);
+ if (searchFilter) {
+ stages.push({ $match: searchFilter });
+ }
+ return stages;
+ }
+
+ private buildBlockedUserSearch(search?: string): PipelineStage.Match['$match'] | undefined {
+ const q = search?.trim();
+ if (!q) {
+ return undefined;
+ }
+
+ const regex = { $regex: q, $options: 'i' };
+ return {
+ $or: [
+ { 'blockedUser.name': regex },
+ { 'blockedUser.username': regex },
+ { 'blockedUser.stageName': regex },
+ ],
+ };
+ }
}
diff --git a/src/modules/blocks/blocks.service.spec.ts b/src/modules/blocks/blocks.service.spec.ts
new file mode 100644
index 0000000..47f845b
--- /dev/null
+++ b/src/modules/blocks/blocks.service.spec.ts
@@ -0,0 +1,57 @@
+import { Types } from 'mongoose';
+import { BlocksService } from './blocks.service';
+
+describe('BlocksService listBlockedUsers', () => {
+ it('returns blocked users with pagination metadata', async () => {
+ const currentUserId = new Types.ObjectId().toString();
+ const blockedUserId = new Types.ObjectId();
+ const blockedAt = new Date('2026-06-01T00:00:00.000Z');
+ const blocksRepository = {
+ findBlockedUsers: jest.fn().mockResolvedValue([
+ {
+ blockedUser: {
+ _id: blockedUserId,
+ name: 'Blocked User',
+ username: 'blocked_user',
+ avatar: '',
+ stageName: 'Stage',
+ },
+ createdAt: blockedAt,
+ },
+ ]),
+ countBlockedUsers: jest.fn().mockResolvedValue(1),
+ };
+
+ const service = new BlocksService(blocksRepository as any, {} as any);
+
+ await expect(service.listBlockedUsers(currentUserId, { page: 1, limit: 20 })).resolves.toEqual({
+ items: [
+ {
+ userId: blockedUserId.toString(),
+ name: 'Blocked User',
+ username: 'blocked_user',
+ avatar: '',
+ stageName: 'Stage',
+ blockedAt,
+ },
+ ],
+ pagination: { page: 1, limit: 20, total: 1, pages: 1 },
+ });
+ });
+
+ it('removes a user from the block list through unblock', async () => {
+ const currentUserId = new Types.ObjectId().toString();
+ const targetUserId = new Types.ObjectId().toString();
+ const blocksRepository = {
+ remove: jest.fn(),
+ };
+
+ const service = new BlocksService(blocksRepository as any, {} as any);
+
+ await expect(service.unblock(currentUserId, targetUserId)).resolves.toEqual({
+ blocked: false,
+ targetUserId,
+ });
+ expect(blocksRepository.remove).toHaveBeenCalledWith(currentUserId, targetUserId);
+ });
+});
diff --git a/src/modules/blocks/blocks.service.ts b/src/modules/blocks/blocks.service.ts
index 6ec6c06..35fc544 100644
--- a/src/modules/blocks/blocks.service.ts
+++ b/src/modules/blocks/blocks.service.ts
@@ -1,7 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
+import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
import { UsersRepository } from '../users/users.repository';
import { BlocksRepository } from './blocks.repository';
+import { BlockListQueryDto } from './dto/block-list-query.dto';
@Injectable()
export class BlocksService {
@@ -27,6 +29,36 @@ export class BlocksService {
return { blocked: false, targetUserId };
}
+ async listBlockedUsers(currentUserId: string, query: BlockListQueryDto) {
+ const page = query.page ?? 1;
+ const limit = query.limit ?? 20;
+ const skip = (page - 1) * limit;
+ const [rows, total] = await Promise.all([
+ this.blocksRepository.findBlockedUsers(currentUserId, skip, limit, query.search),
+ this.blocksRepository.countBlockedUsers(currentUserId, query.search),
+ ]);
+
+ return {
+ items: rows.map((row) => {
+ const blockedUser = row.blockedUser;
+ return {
+ userId: blockedUser._id?.toString?.() ?? '',
+ name: blockedUser.name ?? '',
+ username: blockedUser.username ?? '',
+ avatar: resolveManagedFileUrl(blockedUser.avatar ?? ''),
+ stageName: blockedUser.stageName ?? '',
+ blockedAt: (row as unknown as { createdAt?: Date }).createdAt ?? null,
+ };
+ }),
+ pagination: {
+ page,
+ limit,
+ total,
+ pages: Math.ceil(total / limit),
+ },
+ };
+ }
+
async getStatus(currentUserId: string, targetUserId: string) {
this.assertValidTarget(currentUserId, targetUserId);
const [iBlocked, blockedMe] = await Promise.all([
@@ -45,6 +77,16 @@ export class BlocksService {
return this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
}
+ async hasBlockBetween(currentUserId: string, targetUserId: string): Promise {
+ if (!Types.ObjectId.isValid(currentUserId) || !Types.ObjectId.isValid(targetUserId)) {
+ return false;
+ }
+ if (currentUserId === targetUserId) {
+ return false;
+ }
+ return !!(await this.blocksRepository.findAnyBetween(currentUserId, targetUserId));
+ }
+
async assertNoBlockBetween(currentUserId: string, targetUserId: string): Promise {
this.assertValidTarget(currentUserId, targetUserId);
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
diff --git a/src/modules/blocks/dto/block-list-query.dto.ts b/src/modules/blocks/dto/block-list-query.dto.ts
new file mode 100644
index 0000000..836ea7d
--- /dev/null
+++ b/src/modules/blocks/dto/block-list-query.dto.ts
@@ -0,0 +1,10 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsOptional, IsString } from 'class-validator';
+import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
+
+export class BlockListQueryDto extends PaginationQueryDto {
+ @ApiPropertyOptional({ description: 'Search blocked users by name, username, or stage name' })
+ @IsOptional()
+ @IsString()
+ search?: string;
+}
diff --git a/src/modules/comments/comments.module.ts b/src/modules/comments/comments.module.ts
index 7249ce0..40d1bd5 100644
--- a/src/modules/comments/comments.module.ts
+++ b/src/modules/comments/comments.module.ts
@@ -6,6 +6,7 @@ import { PostsModule } from '../posts/posts.module';
import { UsersModule } from '../users/users.module';
import { Like, LikeSchema } from '../likes/schemas/like.schema';
import { FollowsModule } from '../follows/follows.module';
+import { BlocksModule } from '../blocks/blocks.module';
import { Comment, CommentSchema } from './schemas/comment.schema';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@@ -22,6 +23,7 @@ import { CommentsRepository } from './comments.repository';
NotificationsModule,
UsersModule,
FollowsModule,
+ BlocksModule,
],
controllers: [CommentsController],
providers: [CommentsService, CommentsRepository],
diff --git a/src/modules/comments/comments.service.spec.ts b/src/modules/comments/comments.service.spec.ts
index 0524ace..e5a292c 100644
--- a/src/modules/comments/comments.service.spec.ts
+++ b/src/modules/comments/comments.service.spec.ts
@@ -44,6 +44,9 @@ describe('CommentsService', () => {
const followsRepository = {
findOne: jest.fn(),
};
+ const blocksService = {
+ hasBlockBetween: jest.fn().mockResolvedValue(false),
+ };
const service = new CommentsService(
commentsRepository as any,
@@ -53,6 +56,7 @@ describe('CommentsService', () => {
notificationsService as any,
usersRepository as any,
followsRepository as any,
+ blocksService as any,
);
const result = await service.update(userId, commentId, {
@@ -82,4 +86,41 @@ describe('CommentsService', () => {
}),
);
});
+
+ it('blocks comments when the commenter and post author have a block relation', async () => {
+ const userId = new Types.ObjectId().toString();
+ const authorId = new Types.ObjectId().toString();
+ const postId = new Types.ObjectId().toString();
+ const commentsRepository = {
+ create: jest.fn(),
+ };
+ const postsRepository = {
+ findById: jest.fn().mockResolvedValue({
+ id: postId,
+ authorId: new Types.ObjectId(authorId),
+ commentsDisabled: false,
+ commentsFollowersOnly: false,
+ }),
+ setCommentsCount: jest.fn(),
+ };
+ const blocksService = {
+ hasBlockBetween: jest.fn().mockResolvedValue(true),
+ };
+
+ const service = new CommentsService(
+ commentsRepository as any,
+ postsRepository as any,
+ { logSuperAdminAction: jest.fn() } as any,
+ { bumpGlobalVersion: jest.fn() } as any,
+ { createCommentNotification: jest.fn(), createMentionNotification: jest.fn() } as any,
+ { findByUsernames: jest.fn() } as any,
+ { findOne: jest.fn() } as any,
+ blocksService as any,
+ );
+
+ await expect(
+ service.create(userId, { postId, content: 'Blocked comment' }),
+ ).rejects.toThrow('Post not found');
+ expect(commentsRepository.create).not.toHaveBeenCalled();
+ });
});
diff --git a/src/modules/comments/comments.service.ts b/src/modules/comments/comments.service.ts
index faab325..4c5bc8d 100644
--- a/src/modules/comments/comments.service.ts
+++ b/src/modules/comments/comments.service.ts
@@ -6,6 +6,7 @@ import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { AuditService } from '../audit/audit.service';
import { NotificationsService } from '../notifications/notifications.service';
+import { BlocksService } from '../blocks/blocks.service';
import { FollowsRepository } from '../follows/follows.repository';
import { PostsRepository } from '../posts/posts.repository';
import { UsersRepository } from '../users/users.repository';
@@ -48,6 +49,7 @@ export class CommentsService {
private readonly notificationsService: NotificationsService,
private readonly usersRepository: UsersRepository,
private readonly followsRepository: FollowsRepository,
+ private readonly blocksService: BlocksService,
) {}
async create(userId: string, dto: CreateCommentDto) {
@@ -63,6 +65,7 @@ export class CommentsService {
if (!parent || parent.postId.toString() !== dto.postId) {
throw new NotFoundException('Parent comment not found');
}
+ await this.assertNoBlockBetween(userId, parent.authorId.toString());
parentRecipientId = parent.authorId.toString();
}
@@ -391,15 +394,27 @@ export class CommentsService {
const authorId = this.extractEntityId(post.authorId);
if (!post.commentsFollowersOnly || authorId === userId) {
+ await this.assertNoBlockBetween(userId, authorId);
return;
}
+ await this.assertNoBlockBetween(userId, authorId);
const followsAuthor = await this.followsRepository.findOne(userId, authorId);
if (!followsAuthor) {
throw new ForbiddenException('Only followers can comment on this post');
}
}
+ private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise {
+ if (!targetUserId || actorId === targetUserId) {
+ return;
+ }
+ const blocked = await this.blocksService.hasBlockBetween(actorId, targetUserId);
+ if (blocked) {
+ throw new NotFoundException('Post not found');
+ }
+ }
+
private matchesCommentFilter(content: string, keywords: string[] = []): boolean {
const normalized = content.toLowerCase();
return keywords
@@ -546,6 +561,9 @@ export class CommentsService {
}
for (const recipientId of recipients) {
+ if (await this.blocksService.hasBlockBetween(actorId, recipientId)) {
+ continue;
+ }
try {
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
resourceType: 'post',
@@ -632,6 +650,9 @@ export class CommentsService {
if (excludedRecipientIds.has(mentionedUser.id)) {
continue;
}
+ if (await this.blocksService.hasBlockBetween(actorId, mentionedUser.id)) {
+ continue;
+ }
try {
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
diff --git a/src/modules/follows/follows.service.spec.ts b/src/modules/follows/follows.service.spec.ts
index 4b919e4..cb74d2e 100644
--- a/src/modules/follows/follows.service.spec.ts
+++ b/src/modules/follows/follows.service.spec.ts
@@ -91,4 +91,28 @@ describe('FollowsService', () => {
expect(followsRepository.create).not.toHaveBeenCalled();
expect(outboxService.enqueueFollowNotification).not.toHaveBeenCalled();
});
+
+ it('blocks follow when either user has blocked the other', async () => {
+ const currentUserId = '507f1f77bcf86cd799439011';
+ const targetUserId = '507f191e810c19729de860ea';
+ const followsRepository = {
+ findOne: jest.fn(),
+ create: jest.fn(),
+ };
+ const usersRepository = {
+ findById: jest.fn().mockResolvedValue({ id: targetUserId, isDisabled: false, isPrivate: false }),
+ };
+ const service = new FollowsService(
+ followsRepository as any,
+ usersRepository as any,
+ { enqueueFollowNotification: jest.fn() } as any,
+ { bumpGlobalVersion: jest.fn() } as any,
+ { findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }) } as any,
+ );
+
+ await expect(service.followUser(currentUserId, targetUserId)).rejects.toThrow(
+ 'You cannot follow this user',
+ );
+ expect(followsRepository.create).not.toHaveBeenCalled();
+ });
});
diff --git a/src/modules/likes/likes.module.ts b/src/modules/likes/likes.module.ts
index 598ab2f..feccdd1 100644
--- a/src/modules/likes/likes.module.ts
+++ b/src/modules/likes/likes.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CommentsModule } from '../comments/comments.module';
+import { BlocksModule } from '../blocks/blocks.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module';
import { Like, LikeSchema } from './schemas/like.schema';
@@ -14,6 +15,7 @@ import { LikesService } from './likes.service';
PostsModule,
CommentsModule,
NotificationsModule,
+ BlocksModule,
],
controllers: [LikesController],
providers: [LikesService, LikesRepository],
diff --git a/src/modules/likes/likes.service.spec.ts b/src/modules/likes/likes.service.spec.ts
index 02ffd89..915d0b1 100644
--- a/src/modules/likes/likes.service.spec.ts
+++ b/src/modules/likes/likes.service.spec.ts
@@ -18,6 +18,7 @@ describe('LikesService', () => {
commentsRepository as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ createLikeNotification: jest.fn() } as any,
+ { hasBlockBetween: jest.fn().mockResolvedValue(false) } as any,
);
await expect(
@@ -30,4 +31,35 @@ describe('LikesService', () => {
});
expect(likesRepository.findOne).not.toHaveBeenCalled();
});
+
+ it('blocks likes when the actor and content owner have a block relation', async () => {
+ const likesRepository = {
+ findOne: jest.fn(),
+ create: jest.fn(),
+ };
+ const postsRepository = {
+ findById: jest.fn().mockResolvedValue({
+ id: '507f1f77bcf86cd799439012',
+ authorId: '507f1f77bcf86cd799439013',
+ content: 'Post',
+ }),
+ incrementLikesCount: jest.fn(),
+ };
+ const service = new LikesService(
+ likesRepository as any,
+ postsRepository as any,
+ { findById: jest.fn() } as any,
+ { bumpGlobalVersion: jest.fn() } as any,
+ { createLikeNotification: jest.fn() } as any,
+ { hasBlockBetween: jest.fn().mockResolvedValue(true) } as any,
+ );
+
+ await expect(
+ service.like('507f1f77bcf86cd799439011', {
+ targetId: '507f1f77bcf86cd799439012',
+ targetType: 'post',
+ }),
+ ).rejects.toThrow('Target not found');
+ expect(likesRepository.create).not.toHaveBeenCalled();
+ });
});
diff --git a/src/modules/likes/likes.service.ts b/src/modules/likes/likes.service.ts
index 0785091..afa0d3d 100644
--- a/src/modules/likes/likes.service.ts
+++ b/src/modules/likes/likes.service.ts
@@ -3,6 +3,7 @@ import { Types } from 'mongoose';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { NotificationsService } from '../notifications/notifications.service';
+import { BlocksService } from '../blocks/blocks.service';
import { CommentsRepository } from '../comments/comments.repository';
import { PostsRepository } from '../posts/posts.repository';
import { LikesRepository } from './likes.repository';
@@ -18,6 +19,7 @@ export class LikesService {
private readonly commentsRepository: CommentsRepository,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
+ private readonly blocksService: BlocksService,
) {}
async toggle(userId: string, dto: ToggleLikeDto) {
@@ -28,6 +30,7 @@ export class LikesService {
async like(userId: string, dto: ToggleLikeDto) {
await this.assertTargetExists(dto);
const notificationContext = await this.resolveNotificationContext(dto);
+ await this.assertNoBlockBetween(userId, notificationContext.recipientId);
const reactionType = dto.reactionType ?? ReactionType.LIKE;
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
@@ -49,7 +52,11 @@ export class LikesService {
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
}
await this.feedVersionService.bumpGlobalVersion();
- if (notificationContext.recipientId && notificationContext.recipientId !== userId) {
+ if (
+ notificationContext.recipientId &&
+ notificationContext.recipientId !== userId &&
+ !(await this.blocksService.hasBlockBetween(userId, notificationContext.recipientId))
+ ) {
try {
await this.notificationsService.createLikeNotification(
userId,
@@ -144,6 +151,15 @@ export class LikesService {
};
}
+ private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise {
+ if (!targetUserId || actorId === targetUserId) {
+ return;
+ }
+ if (await this.blocksService.hasBlockBetween(actorId, targetUserId)) {
+ throw new NotFoundException('Target not found');
+ }
+ }
+
private extractEntityId(value: unknown): string {
if (!value) {
return '';
diff --git a/src/modules/metadata/metadata.controller.ts b/src/modules/metadata/metadata.controller.ts
new file mode 100644
index 0000000..dfbba42
--- /dev/null
+++ b/src/modules/metadata/metadata.controller.ts
@@ -0,0 +1,14 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { MetadataService } from './metadata.service';
+
+@ApiTags('Metadata')
+@Controller('metadata')
+export class MetadataController {
+ constructor(private readonly metadataService: MetadataService) {}
+
+ @Get('profile-options')
+ getProfileOptions() {
+ return this.metadataService.getProfileOptions();
+ }
+}
diff --git a/src/modules/metadata/metadata.module.ts b/src/modules/metadata/metadata.module.ts
new file mode 100644
index 0000000..84cfc87
--- /dev/null
+++ b/src/modules/metadata/metadata.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { MetadataController } from './metadata.controller';
+import { MetadataService } from './metadata.service';
+
+@Module({
+ controllers: [MetadataController],
+ providers: [MetadataService],
+ exports: [MetadataService],
+})
+export class MetadataModule {}
diff --git a/src/modules/metadata/metadata.service.spec.ts b/src/modules/metadata/metadata.service.spec.ts
new file mode 100644
index 0000000..6a45124
--- /dev/null
+++ b/src/modules/metadata/metadata.service.spec.ts
@@ -0,0 +1,17 @@
+import { MetadataService } from './metadata.service';
+
+describe('MetadataService', () => {
+ it('returns profile dropdown options', () => {
+ const service = new MetadataService();
+
+ expect(service.getProfileOptions()).toEqual(
+ expect.objectContaining({
+ musicRoles: expect.arrayContaining(['instrumentalist', 'teacher']),
+ maqams: expect.arrayContaining(['Hijaz', 'Rast']),
+ instruments: expect.arrayContaining(['Oud', 'Piano']),
+ experienceLevels: expect.arrayContaining(['beginner', 'professional']),
+ moods: expect.arrayContaining(['Tarab', 'Classical']),
+ }),
+ );
+ });
+});
diff --git a/src/modules/metadata/metadata.service.ts b/src/modules/metadata/metadata.service.ts
new file mode 100644
index 0000000..2cff57a
--- /dev/null
+++ b/src/modules/metadata/metadata.service.ts
@@ -0,0 +1,9 @@
+import { Injectable } from '@nestjs/common';
+import { PROFILE_OPTIONS, ProfileOptionsResponse } from './profile-options.constants';
+
+@Injectable()
+export class MetadataService {
+ getProfileOptions(): ProfileOptionsResponse {
+ return PROFILE_OPTIONS;
+ }
+}
diff --git a/src/modules/metadata/profile-options.constants.ts b/src/modules/metadata/profile-options.constants.ts
new file mode 100644
index 0000000..b86a6b5
--- /dev/null
+++ b/src/modules/metadata/profile-options.constants.ts
@@ -0,0 +1,22 @@
+import { ExperienceLevel } from '../../common/enums/experience-level.enum';
+import { MusicRole } from '../../common/enums/music-role.enum';
+
+export const PROFILE_OPTIONS = {
+ musicRoles: [
+ MusicRole.INSTRUMENTALIST,
+ MusicRole.SINGER,
+ MusicRole.COMPOSER,
+ MusicRole.LYRICIST,
+ MusicRole.PRODUCER,
+ MusicRole.ARRANGER,
+ MusicRole.TEACHER,
+ MusicRole.STUDENT,
+ MusicRole.CONTENT_CREATOR,
+ ],
+ maqams: ['Hijaz', 'Bayati', 'Rast', 'Kurd', 'Saba', 'Nahawand', 'Ajam'],
+ instruments: ['Oud', 'Qanun', 'Nay', 'Violin', 'Piano', 'Guitar', 'Percussion'],
+ experienceLevels: Object.values(ExperienceLevel),
+ moods: ['Tarab', 'Eastern', 'Calm', 'Sad', 'Energetic', 'Classical'],
+} as const;
+
+export type ProfileOptionsResponse = typeof PROFILE_OPTIONS;
diff --git a/src/modules/posts/dto/create-post.dto.ts b/src/modules/posts/dto/create-post.dto.ts
index 1cb047d..c20b75f 100644
--- a/src/modules/posts/dto/create-post.dto.ts
+++ b/src/modules/posts/dto/create-post.dto.ts
@@ -24,6 +24,18 @@ export class CreatePostDto {
@Length(0, 2200)
content?: string;
+ @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above post media' })
+ @IsOptional()
+ @IsString()
+ @Length(0, 2200)
+ contentTop?: string;
+
+ @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below post media' })
+ @IsOptional()
+ @IsString()
+ @Length(0, 2200)
+ contentBottom?: string;
+
@ApiPropertyOptional({ description: 'Single video URL (optional)' })
@IsOptional()
@IsUrl({ require_tld: false })
diff --git a/src/modules/posts/dto/create-reel.dto.ts b/src/modules/posts/dto/create-reel.dto.ts
index f570bd6..c68ce07 100644
--- a/src/modules/posts/dto/create-reel.dto.ts
+++ b/src/modules/posts/dto/create-reel.dto.ts
@@ -22,6 +22,18 @@ export class CreateReelDto {
@Length(0, 2200)
content?: string;
+ @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above reel media' })
+ @IsOptional()
+ @IsString()
+ @Length(0, 2200)
+ contentTop?: string;
+
+ @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below reel media' })
+ @IsOptional()
+ @IsString()
+ @Length(0, 2200)
+ contentBottom?: string;
+
@ApiPropertyOptional({ description: 'Reel video URL (if not uploading videoFile)' })
@IsOptional()
@IsUrl({ require_tld: false })
diff --git a/src/modules/posts/dto/post-query.dto.ts b/src/modules/posts/dto/post-query.dto.ts
index 58a89b3..07fea53 100644
--- a/src/modules/posts/dto/post-query.dto.ts
+++ b/src/modules/posts/dto/post-query.dto.ts
@@ -1,4 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { PostType } from '../../../common/enums/post-type.enum';
@@ -18,6 +19,13 @@ export const POST_SORT_FIELDS = [
export type PostSortField = (typeof POST_SORT_FIELDS)[number];
export const POST_VISIBILITY_FILTERS = [...Object.values(PostVisibility), 'archived'] as const;
export type PostVisibilityFilter = (typeof POST_VISIBILITY_FILTERS)[number];
+export const POST_MEDIA_TYPE_FILTERS = [...Object.values(PostType), 'reel'] as const;
+export type PostMediaTypeFilter = (typeof POST_MEDIA_TYPE_FILTERS)[number];
+
+const normalizePostTypeFilter = ({ value }: { value: unknown }) =>
+ typeof value === 'string' && value.trim().toLowerCase() === 'reel'
+ ? PostType.VIDEO
+ : value;
export class PostQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: POST_VISIBILITY_FILTERS })
@@ -27,9 +35,18 @@ export class PostQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: PostType })
@IsOptional()
+ @Transform(normalizePostTypeFilter)
@IsEnum(PostType)
postType?: PostType;
+ @ApiPropertyOptional({
+ enum: POST_MEDIA_TYPE_FILTERS,
+ description: 'Optional alias for postType. The reel value maps to video posts.',
+ })
+ @IsOptional()
+ @IsIn(POST_MEDIA_TYPE_FILTERS)
+ mediaType?: PostMediaTypeFilter;
+
@ApiPropertyOptional({ description: 'Search inside post content' })
@IsOptional()
@IsString()
diff --git a/src/modules/posts/dto/update-post.dto.ts b/src/modules/posts/dto/update-post.dto.ts
index 176dece..6e6d3b4 100644
--- a/src/modules/posts/dto/update-post.dto.ts
+++ b/src/modules/posts/dto/update-post.dto.ts
@@ -24,6 +24,18 @@ export class UpdatePostDto {
@Length(1, 2200)
content?: string;
+ @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed above post media' })
+ @IsOptional()
+ @IsString()
+ @Length(0, 2200)
+ contentTop?: string;
+
+ @ApiPropertyOptional({ maxLength: 2200, description: 'Text displayed below post media' })
+ @IsOptional()
+ @IsString()
+ @Length(0, 2200)
+ contentBottom?: string;
+
@ApiPropertyOptional({ description: 'Set video URL. If provided, audioUrl will be cleared.' })
@IsOptional()
@IsUrl({ require_tld: false })
diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts
index 6df14dc..ce4fd1b 100644
--- a/src/modules/posts/posts.controller.ts
+++ b/src/modules/posts/posts.controller.ts
@@ -60,6 +60,8 @@ export class PostsController {
type: 'object',
properties: {
content: { type: 'string', example: 'First post #music' },
+ contentTop: { type: 'string', example: 'Before the performance #oud' },
+ contentBottom: { type: 'string', example: 'Full session caption' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
imageUrls: { type: 'array', items: { type: 'string' } },
imageCaptions: { type: 'array', items: { type: 'string' } },
@@ -143,6 +145,8 @@ export class PostsController {
type: 'object',
properties: {
content: { type: 'string', example: 'New reel from oud session #reel' },
+ contentTop: { type: 'string', example: 'Live from the studio' },
+ contentBottom: { type: 'string', example: 'New reel from oud session #reel' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
videoUrl: { type: 'string', example: 'https://cdn.example.com/reel.mp4' },
durationSeconds: { type: 'number', example: 42 },
@@ -199,6 +203,8 @@ export class PostsController {
type: 'object',
properties: {
content: { type: 'string', example: 'Updated content' },
+ contentTop: { type: 'string', example: 'Updated top text' },
+ contentBottom: { type: 'string', example: 'Updated bottom text' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
imageUrls: { type: 'array', items: { type: 'string' } },
imageCaptions: { type: 'array', items: { type: 'string' } },
diff --git a/src/modules/posts/posts.service.spec.ts b/src/modules/posts/posts.service.spec.ts
index a8f2cca..55012fa 100644
--- a/src/modules/posts/posts.service.spec.ts
+++ b/src/modules/posts/posts.service.spec.ts
@@ -5,6 +5,13 @@ import { PostsService } from './posts.service';
const createService = () => {
const postsRepository = {
+ create: jest.fn((authorId: string, payload: Record) =>
+ Promise.resolve({
+ id: new Types.ObjectId().toString(),
+ authorId: new Types.ObjectId(authorId),
+ ...payload,
+ }),
+ ),
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
findById: jest.fn(),
@@ -19,6 +26,9 @@ const createService = () => {
insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }),
};
const usersRepository = {
+ incrementPostsCount: jest.fn().mockResolvedValue(undefined),
+ findByUsernames: jest.fn().mockResolvedValue([]),
+ findMany: jest.fn().mockResolvedValue([]),
findById: jest.fn().mockResolvedValue({
id: new Types.ObjectId().toString(),
isDisabled: false,
@@ -27,6 +37,7 @@ const createService = () => {
};
const notificationsService = {
createShareNotification: jest.fn().mockResolvedValue(undefined),
+ createMentionNotification: jest.fn().mockResolvedValue(undefined),
};
const service = new PostsService(
@@ -125,6 +136,40 @@ describe('PostsService archived profile posts', () => {
expect.any(Object),
);
});
+
+ it('filters archived posts by postType image', async () => {
+ const userId = new Types.ObjectId().toString();
+ const { service, postsRepository } = createService();
+
+ await service.findUserPosts(userId, { visibility: 'archived', postType: 'image' as any }, userId);
+
+ expect(postsRepository.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isArchived: true,
+ postType: 'image',
+ }),
+ 0,
+ 20,
+ expect.any(Object),
+ );
+ });
+
+ it('maps archived mediaType=reel to video posts', async () => {
+ const userId = new Types.ObjectId().toString();
+ const { service, postsRepository } = createService();
+
+ await service.findUserPosts(userId, { visibility: 'archived', mediaType: 'reel' as any }, userId);
+
+ expect(postsRepository.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isArchived: true,
+ postType: 'video',
+ }),
+ 0,
+ 20,
+ expect.any(Object),
+ );
+ });
});
describe('PostsService post sharing', () => {
@@ -205,6 +250,117 @@ describe('PostsService post sharing', () => {
});
});
+describe('PostsService post text placement', () => {
+ it('keeps legacy content-only posts as bottom-compatible content', async () => {
+ const userId = new Types.ObjectId().toString();
+ const { service, postsRepository } = createService();
+
+ await service.create(userId, { content: 'Legacy caption #oud' });
+
+ expect(postsRepository.create).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({
+ content: 'Legacy caption #oud',
+ contentTop: '',
+ contentBottom: '',
+ hashtags: ['oud'],
+ }),
+ );
+ });
+
+ it('creates a post with top text only', async () => {
+ const userId = new Types.ObjectId().toString();
+ const { service, postsRepository } = createService();
+
+ await service.create(userId, { contentTop: 'Top line #intro' });
+
+ expect(postsRepository.create).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({
+ content: '',
+ contentTop: 'Top line #intro',
+ contentBottom: '',
+ hashtags: ['intro'],
+ }),
+ );
+ });
+
+ it('creates a post with bottom text only', async () => {
+ const userId = new Types.ObjectId().toString();
+ const { service, postsRepository } = createService();
+
+ await service.create(userId, { contentBottom: 'Bottom caption #outro' });
+
+ expect(postsRepository.create).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({
+ content: 'Bottom caption #outro',
+ contentTop: '',
+ contentBottom: 'Bottom caption #outro',
+ hashtags: ['outro'],
+ }),
+ );
+ });
+
+ it('creates a post with top and bottom text together', async () => {
+ const userId = new Types.ObjectId().toString();
+ const { service, postsRepository } = createService();
+
+ await service.create(userId, {
+ contentTop: 'Top #same',
+ contentBottom: 'Bottom #different',
+ });
+
+ expect(postsRepository.create).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({
+ content: 'Bottom #different',
+ contentTop: 'Top #same',
+ contentBottom: 'Bottom #different',
+ hashtags: expect.arrayContaining(['same', 'different']),
+ }),
+ );
+ });
+
+ it('extracts hashtags and mentions from content, contentTop, and contentBottom without duplicates', async () => {
+ const userId = new Types.ObjectId().toString();
+ const mentionedUserId = new Types.ObjectId().toString();
+ const { service, postsRepository, usersRepository, notificationsService } = createService();
+ usersRepository.findByUsernames.mockResolvedValue([
+ {
+ id: mentionedUserId,
+ username: 'singer',
+ name: 'Singer',
+ isDisabled: false,
+ },
+ ]);
+
+ await service.create(userId, {
+ content: 'Legacy @singer #oud',
+ contentTop: 'Top @singer #oud',
+ contentBottom: 'Bottom #maqam',
+ });
+
+ expect(postsRepository.create).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({
+ mentionUsernames: ['singer'],
+ mentionedUserIds: [new Types.ObjectId(mentionedUserId)],
+ hashtags: expect.arrayContaining(['oud', 'maqam']),
+ }),
+ );
+ expect(notificationsService.createMentionNotification).toHaveBeenCalledWith(
+ userId,
+ mentionedUserId,
+ expect.any(String),
+ expect.objectContaining({
+ resourceType: 'post',
+ previewText: expect.stringContaining('Legacy @singer #oud'),
+ }),
+ );
+ });
+});
+
describe('Post schema response aliases', () => {
it('returns isPinned as a stable alias for pinnedToProfile', () => {
const transform = PostSchema.get('toObject')?.transform as (
@@ -221,4 +377,23 @@ describe('Post schema response aliases', () => {
expect(ret.pinnedToProfile).toBe(true);
expect(ret.isPinned).toBe(true);
});
+
+ it('returns contentTop and contentBottom in serialized post responses', () => {
+ const transform = PostSchema.get('toObject')?.transform as (
+ doc: unknown,
+ ret: Record,
+ ) => Record;
+ const ret = transform(null, {
+ postType: 'text',
+ content: 'Legacy caption',
+ contentTop: 'Top text',
+ contentBottom: 'Bottom text',
+ waveformPeaks: [],
+ mentionedUserIds: [],
+ });
+
+ expect(ret.content).toBe('Legacy caption');
+ expect(ret.contentTop).toBe('Top text');
+ expect(ret.contentBottom).toBe('Bottom text');
+ });
});
diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts
index f48b9a5..bbba896 100644
--- a/src/modules/posts/posts.service.ts
+++ b/src/modules/posts/posts.service.ts
@@ -178,7 +178,12 @@ export class PostsService {
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
- const finalContent = dto.content?.trim() ?? '';
+ const finalContentTop = dto.contentTop?.trim() ?? '';
+ const finalContentBottom = dto.contentBottom?.trim() ?? '';
+ const finalLegacyContent = dto.content?.trim() ?? '';
+ const finalContent =
+ typeof dto.contentBottom === 'string' ? finalContentBottom : finalLegacyContent;
+ const finalText = this.combinePostText(finalLegacyContent, finalContentTop, finalContentBottom);
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
const collaboratorIds = await this.normalizeUserIdList(
dto.collaboratorIds,
@@ -190,20 +195,20 @@ export class PostsService {
const mentionResolution = await this.resolveMentionTargets(
dto.mentionUsernames,
dto.mentionedUserIds,
- finalContent,
+ finalText,
userId,
);
const { location, latitude, longitude } = this.normalizeLocation(dto);
- if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
+ if (!finalText && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
throw new BadRequestException('Post must contain caption or media');
}
const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl);
- const hashtags = this.extractHashtags(finalContent);
+ const hashtags = this.extractHashtags(finalText);
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
audioSourceBuffer: audioFile?.buffer,
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
- waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
+ waveformSeed: finalAudioUrl || finalText || `${userId}:${Date.now()}`,
thumbnailUrl: uploadedThumbnailUrl,
});
@@ -211,6 +216,8 @@ export class PostsService {
try {
post = await this.postsRepository.create(userId, {
content: finalContent,
+ contentTop: finalContentTop,
+ contentBottom: finalContentBottom,
imageUrls: finalImageUrls,
imageItems,
imageVariants: finalImageVariants,
@@ -261,7 +268,7 @@ export class PostsService {
userId,
post.id,
mentionResolution.mentionedUsers,
- finalContent,
+ finalText,
);
return (await this.postsRepository.findById(post.id)) ?? post;
}
@@ -380,7 +387,16 @@ export class PostsService {
? null
: existingThumbnailVariants;
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl);
- const nextContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? '');
+ const nextContentTop =
+ typeof dto.contentTop === 'string' ? dto.contentTop.trim() : (post.contentTop ?? '');
+ const nextContentBottom =
+ typeof dto.contentBottom === 'string' ? dto.contentBottom.trim() : (post.contentBottom ?? '');
+ const nextLegacyContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? '');
+ const nextContent =
+ typeof dto.contentBottom === 'string'
+ ? nextContentBottom
+ : nextLegacyContent;
+ const nextText = this.combinePostText(nextLegacyContent, nextContentTop, nextContentBottom);
const nextTaggedUserIds =
typeof dto.taggedUserIds !== 'undefined'
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId)
@@ -405,10 +421,12 @@ export class PostsService {
).filter(Boolean);
const shouldRecomputeMentions =
typeof dto.content === 'string' ||
+ typeof dto.contentTop === 'string' ||
+ typeof dto.contentBottom === 'string' ||
typeof dto.mentionUsernames !== 'undefined' ||
typeof dto.mentionedUserIds !== 'undefined';
const mentionResolution = shouldRecomputeMentions
- ? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, nextContent, userId)
+ ? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, nextText, userId)
: {
mentionUsernames: previousMentionUsernames,
mentionedUserIds: previousMentionedUserIds.map((id) => new Types.ObjectId(id)),
@@ -423,7 +441,7 @@ export class PostsService {
latitude: post.latitude ?? null,
longitude: post.longitude ?? null,
});
- if (!nextContent && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) {
+ if (!nextText && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) {
throw new BadRequestException('Post must contain caption or media');
}
const mediaMetadata = this.normalizeMediaMetadata(
@@ -442,7 +460,7 @@ export class PostsService {
{
audioSourceBuffer: audioFile?.buffer,
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
- waveformSeed: nextAudioUrl || nextContent || post.id,
+ waveformSeed: nextAudioUrl || nextText || post.id,
thumbnailUrl: uploadedThumbnailUrl,
},
);
@@ -450,6 +468,8 @@ export class PostsService {
const payload: Record = {
...dto,
content: nextContent,
+ contentTop: nextContentTop,
+ contentBottom: nextContentBottom,
imageUrls: nextImageUrls,
imageItems: nextImageItems,
imageVariants: nextImageVariants,
@@ -467,11 +487,15 @@ export class PostsService {
...mediaMetadata,
};
- if (typeof dto.content === 'string') {
- payload.hashtags = this.extractHashtags(nextContent);
+ if (
+ typeof dto.content === 'string' ||
+ typeof dto.contentTop === 'string' ||
+ typeof dto.contentBottom === 'string'
+ ) {
+ payload.hashtags = this.extractHashtags(nextText);
}
if (hasImageUpdate) {
- payload.hashtags = this.extractHashtags(nextContent);
+ payload.hashtags = this.extractHashtags(nextText);
}
if (hasVideoUpdate && !hasAudioUpdate) {
@@ -579,7 +603,7 @@ export class PostsService {
const nextMentionedUsers = mentionResolution.mentionedUsers.filter(
(mentionedUser) => !previousMentionSet.has(mentionedUser.username),
);
- await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, nextContent);
+ await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, nextText);
}
return updated;
}
@@ -664,11 +688,12 @@ export class PostsService {
if (query.visibility && !archivedOnly) {
filter.visibility = query.visibility;
}
- if (query.postType) {
- filter.postType = query.postType;
+ const resolvedPostType = this.resolvePostTypeFilter(query);
+ if (resolvedPostType) {
+ filter.postType = resolvedPostType;
}
if (query.q) {
- filter.content = { $regex: query.q.trim(), $options: 'i' };
+ filter.$or = this.buildTextSearchFilter(query.q);
}
if (query.hashtag) {
filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase();
@@ -696,17 +721,21 @@ export class PostsService {
const skip = (page - 1) * limit;
const filter: Record = {};
- if (query.visibility) {
+ const archivedOnly = query.visibility === 'archived';
+ if (archivedOnly) {
+ filter.isArchived = true;
+ } else if (query.visibility) {
filter.visibility = query.visibility;
}
- if (query.postType) {
- filter.postType = query.postType;
+ const resolvedPostType = this.resolvePostTypeFilter(query);
+ if (resolvedPostType) {
+ filter.postType = resolvedPostType;
}
if (query.authorId) {
filter.authorId = new Types.ObjectId(query.authorId);
}
if (query.q?.trim()) {
- filter.content = { $regex: query.q.trim(), $options: 'i' };
+ filter.$or = this.buildTextSearchFilter(query.q);
}
if (query.hashtag?.trim()) {
filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase();
@@ -750,6 +779,8 @@ export class PostsService {
userId,
{
content: dto.content ?? '',
+ contentTop: dto.contentTop,
+ contentBottom: dto.contentBottom,
videoUrl: dto.videoUrl,
durationSeconds: dto.durationSeconds,
thumbnailUrl: dto.thumbnailUrl,
@@ -779,7 +810,7 @@ export class PostsService {
filter.authorId = new Types.ObjectId(query.authorId);
}
if (query.q) {
- filter.content = { $regex: query.q.trim(), $options: 'i' };
+ filter.$or = this.buildTextSearchFilter(query.q);
}
const direction = resolveMongoSortDirection(query.sortOrder);
const sortField = query.sortBy ?? 'createdAt';
@@ -1129,6 +1160,16 @@ export class PostsService {
return PostType.TEXT;
}
+ private resolvePostTypeFilter(query: Pick): PostType | undefined {
+ if (query.postType) {
+ return query.postType;
+ }
+ if (query.mediaType === 'reel') {
+ return PostType.VIDEO;
+ }
+ return query.mediaType as PostType | undefined;
+ }
+
private normalizeMediaMetadata(
dto: PostMediaMetadataInput,
postType: PostType,
@@ -1230,6 +1271,21 @@ export class PostsService {
return Array.from(new Set(normalized)).slice(0, 30);
}
+ private combinePostText(...values: Array): string {
+ return Array.from(
+ new Set(
+ values
+ .map((value) => value?.trim() ?? '')
+ .filter((value) => value.length > 0),
+ ),
+ ).join('\n\n');
+ }
+
+ private buildTextSearchFilter(query: string) {
+ const pattern = { $regex: query.trim(), $options: 'i' };
+ return [{ content: pattern }, { contentTop: pattern }, { contentBottom: pattern }];
+ }
+
private normalizeMentionUsernames(input: string[] = []): string[] {
return Array.from(
new Set(
diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts
index 9e0b913..53a7dff 100644
--- a/src/modules/posts/schemas/post.schema.ts
+++ b/src/modules/posts/schemas/post.schema.ts
@@ -58,6 +58,12 @@ export class Post {
@Prop({ default: '', trim: true, maxlength: 2200, required: true })
content!: string;
+ @Prop({ default: '', trim: true, maxlength: 2200 })
+ contentTop!: string;
+
+ @Prop({ default: '', trim: true, maxlength: 2200 })
+ contentBottom!: string;
+
@Prop({ default: '' })
videoUrl!: string;
@@ -215,6 +221,8 @@ PostSchema.index({ visibility: 1, isDeleted: 1, createdAt: -1 });
PostSchema.index(
{
content: 'text',
+ contentTop: 'text',
+ contentBottom: 'text',
hashtags: 'text',
style: 'text',
maqam: 'text',
diff --git a/src/modules/saves/saves.module.ts b/src/modules/saves/saves.module.ts
index f07196c..47f3464 100644
--- a/src/modules/saves/saves.module.ts
+++ b/src/modules/saves/saves.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationsModule } from '../notifications/notifications.module';
+import { BlocksModule } from '../blocks/blocks.module';
import { PostsModule } from '../posts/posts.module';
import { Save, SaveSchema } from './schemas/save.schema';
import { SavesController } from './saves.controller';
@@ -12,6 +13,7 @@ import { SavesService } from './saves.service';
MongooseModule.forFeature([{ name: Save.name, schema: SaveSchema }]),
PostsModule,
NotificationsModule,
+ BlocksModule,
],
controllers: [SavesController],
providers: [SavesService, SavesRepository],
diff --git a/src/modules/saves/saves.service.spec.ts b/src/modules/saves/saves.service.spec.ts
index dd6eccd..c7ce423 100644
--- a/src/modules/saves/saves.service.spec.ts
+++ b/src/modules/saves/saves.service.spec.ts
@@ -14,6 +14,7 @@ describe('SavesService', () => {
postsRepository as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ createSaveNotification: jest.fn() } as any,
+ { hasBlockBetween: jest.fn().mockResolvedValue(false) } as any,
);
await expect(
diff --git a/src/modules/saves/saves.service.ts b/src/modules/saves/saves.service.ts
index 23d30e1..0295595 100644
--- a/src/modules/saves/saves.service.ts
+++ b/src/modules/saves/saves.service.ts
@@ -5,6 +5,7 @@ import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { NotificationsService } from '../notifications/notifications.service';
+import { BlocksService } from '../blocks/blocks.service';
import { PostsRepository } from '../posts/posts.repository';
import { ToggleSaveDto } from './dto/toggle-save.dto';
import { SavesRepository } from './saves.repository';
@@ -18,6 +19,7 @@ export class SavesService {
private readonly postsRepository: PostsRepository,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
+ private readonly blocksService: BlocksService,
) {}
async toggle(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> {
@@ -27,6 +29,8 @@ export class SavesService {
async save(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> {
const post = await this.getPostOrThrow(dto.postId);
+ const recipientId = this.extractEntityId(post.authorId);
+ await this.assertNoBlockBetween(userId, recipientId);
const existing = await this.savesRepository.findOne(userId, dto.postId);
if (existing) {
@@ -36,8 +40,11 @@ export class SavesService {
await this.savesRepository.create(userId, dto.postId);
await this.postsRepository.incrementSavesCount(dto.postId, 1);
await this.feedVersionService.bumpGlobalVersion();
- const recipientId = this.extractEntityId(post.authorId);
- if (recipientId && recipientId !== userId) {
+ if (
+ recipientId &&
+ recipientId !== userId &&
+ !(await this.blocksService.hasBlockBetween(userId, recipientId))
+ ) {
try {
await this.notificationsService.createSaveNotification(userId, recipientId, dto.postId, {
resourceType: 'post',
@@ -116,6 +123,15 @@ export class SavesService {
return post;
}
+ private async assertNoBlockBetween(actorId: string, targetUserId: string): Promise {
+ if (!targetUserId || actorId === targetUserId) {
+ return;
+ }
+ if (await this.blocksService.hasBlockBetween(actorId, targetUserId)) {
+ throw new NotFoundException('Post not found');
+ }
+ }
+
private extractEntityId(value: unknown): string {
if (!value) {
return '';
diff --git a/src/modules/users/dto/music-setup.dto.ts b/src/modules/users/dto/music-setup.dto.ts
index b589f99..cf4ca88 100644
--- a/src/modules/users/dto/music-setup.dto.ts
+++ b/src/modules/users/dto/music-setup.dto.ts
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
-import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
+import { IsArray, IsEnum, IsOptional, IsString, Length } from 'class-validator';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum';
@@ -32,4 +32,10 @@ export class MusicSetupDto {
@IsArray()
@IsString({ each: true })
favoriteMaqamat?: string[];
+
+ @ApiPropertyOptional({ example: 'Tarab', maxLength: 80 })
+ @IsOptional()
+ @IsString()
+ @Length(0, 80)
+ preferredMood?: string;
}
diff --git a/src/modules/users/dto/profile-setup.dto.ts b/src/modules/users/dto/profile-setup.dto.ts
index e3daff6..a0cbd10 100644
--- a/src/modules/users/dto/profile-setup.dto.ts
+++ b/src/modules/users/dto/profile-setup.dto.ts
@@ -25,6 +25,12 @@ export class ProfileSetupDto {
@Length(0, 150)
bio?: string;
+ @ApiPropertyOptional({ example: 'Tarab', maxLength: 80 })
+ @IsOptional()
+ @IsString()
+ @Length(0, 80)
+ preferredMood?: string;
+
@ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' })
@IsOptional()
@IsString()
diff --git a/src/modules/users/dto/update-user.dto.ts b/src/modules/users/dto/update-user.dto.ts
index 98aaece..c34cb69 100644
--- a/src/modules/users/dto/update-user.dto.ts
+++ b/src/modules/users/dto/update-user.dto.ts
@@ -119,4 +119,10 @@ export class UpdateUserDto {
@IsArray()
@IsString({ each: true })
favoriteMaqamat?: string[];
+
+ @ApiPropertyOptional({ example: 'Tarab', maxLength: 80 })
+ @IsOptional()
+ @IsString()
+ @Length(0, 80)
+ preferredMood?: string;
}
diff --git a/src/modules/users/schemas/user.schema.ts b/src/modules/users/schemas/user.schema.ts
index ee89ce6..b4aa35d 100644
--- a/src/modules/users/schemas/user.schema.ts
+++ b/src/modules/users/schemas/user.schema.ts
@@ -89,6 +89,9 @@ export class User {
@Prop({ type: [String], default: [] })
favoriteMaqamat!: string[];
+ @Prop({ default: '', trim: true, maxlength: 80 })
+ preferredMood!: string;
+
@Prop({ default: 0, min: 0 })
followersCount!: number;
diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts
index 7183a5f..2022843 100644
--- a/src/modules/users/users.service.spec.ts
+++ b/src/modules/users/users.service.spec.ts
@@ -99,6 +99,13 @@ const createService = (options: {
const usersRepository = {
findById: jest.fn().mockResolvedValue(user),
findOne: jest.fn().mockResolvedValue(user),
+ updateById: jest.fn().mockImplementation((_id: string, payload: Record) =>
+ Promise.resolve({
+ ...user,
+ ...payload,
+ toObject: () => ({ ...user.toObject(), ...payload }),
+ }),
+ ),
};
const service = new UsersService(
connection as any,
@@ -307,3 +314,14 @@ describe('UsersService profile sharing', () => {
);
});
});
+
+describe('UsersService preferredMood', () => {
+ it('updates preferredMood through music setup and returns it', async () => {
+ const { service, userId, usersRepository } = createService();
+
+ const result = await service.updateMusicSetup(userId, { preferredMood: 'Tarab' });
+
+ expect(usersRepository.updateById).toHaveBeenCalledWith(userId, { preferredMood: 'Tarab' });
+ expect(result.toObject()).toEqual(expect.objectContaining({ preferredMood: 'Tarab' }));
+ });
+});
diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts
index 46289e4..a5aa8c9 100644
--- a/src/modules/users/users.service.ts
+++ b/src/modules/users/users.service.ts
@@ -293,6 +293,10 @@ export class UsersService {
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
}
+ async findByIdWithPassword(userId: string): Promise {
+ return this.usersRepository.findByIdWithPassword(userId);
+ }
+
async findByEmail(email: string): Promise {
return this.usersRepository.findOne({ email: email.toLowerCase() });
}
@@ -327,7 +331,7 @@ export class UsersService {
coverImageFile?: UploadedImageFile,
): Promise {
const currentUser = await this.findByIdOrFail(userId);
- const payload: Record = { ...dto };
+ const payload = await this.prepareManagedUserUpdatePayload(userId, dto);
const uploadedImageUrls = await this.attachUploadedProfileImages(
payload,
avatarFile,