Match Figma profile, blocks, auth, and archive backend
هذا الالتزام موجود في:
@@ -319,6 +319,7 @@ export default function ContentPage() {
|
||||
<SelectItem value="public">public</SelectItem>
|
||||
<SelectItem value="followers">followers</SelectItem>
|
||||
<SelectItem value="private">private</SelectItem>
|
||||
<SelectItem value="archived">archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={moderationStatus} onValueChange={setModerationStatus}>
|
||||
@@ -455,7 +456,9 @@ export default function ContentPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="max-w-[240px] truncate">{post.content || "-"}</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate">
|
||||
{post.contentBottom?.trim() || post.content || post.contentTop || "-"}
|
||||
</TableCell>
|
||||
<TableCell>{getUserLabel(getPostAuthor(post))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
@@ -604,7 +607,27 @@ export default function ContentPage() {
|
||||
>
|
||||
{selectedPost ? (
|
||||
<div className="space-y-4">
|
||||
{selectedPost.contentTop?.trim() ? (
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Top text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{selectedPost.contentTop.trim()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<MediaInspector post={selectedPost} />
|
||||
{(selectedPost.contentBottom?.trim() || selectedPost.content?.trim()) ? (
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Bottom text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{selectedPost.contentBottom?.trim() || selectedPost.content.trim()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Post metadata</CardTitle>
|
||||
|
||||
@@ -73,6 +73,13 @@ function buildEditPayload(user: ApiUser): Partial<ApiUser> {
|
||||
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<ApiUser>,
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Avatar URL"
|
||||
value={String(editPayload.avatar ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, avatar: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Cover image URL"
|
||||
value={String(editPayload.coverImage ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, coverImage: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Music roles, comma separated"
|
||||
value={normalizeStringList(editPayload.musicRoles).join(", ")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, musicRoles: normalizeStringList(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Experience level"
|
||||
value={String(editPayload.experienceLevel ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, experienceLevel: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Favorite maqamat, comma separated"
|
||||
value={normalizeStringList(editPayload.favoriteMaqamat).join(", ")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({
|
||||
...prev,
|
||||
favoriteMaqamat: normalizeStringList(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Favorite instruments, comma separated"
|
||||
value={normalizeStringList(editPayload.favoriteInstruments).join(", ")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({
|
||||
...prev,
|
||||
favoriteInstruments: normalizeStringList(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Preferred mood"
|
||||
value={String(editPayload.preferredMood ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, preferredMood: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
disabled={!canManageUsers}
|
||||
value={String(editPayload.role ?? selectedUser.role ?? "user")}
|
||||
@@ -859,6 +997,13 @@ export default function UsersPage() {
|
||||
<FieldRow label="Role" value={selectedUser.role} />
|
||||
<FieldRow label="Email" value={selectedUser.email} valueDir="ltr" />
|
||||
<FieldRow label="Location" value={selectedUser.location} />
|
||||
<FieldRow label="Avatar" value={selectedUser.avatar} valueDir="ltr" />
|
||||
<FieldRow label="Cover" value={selectedUser.coverImage} valueDir="ltr" />
|
||||
<FieldRow label="Music roles" value={selectedUser.musicRoles} />
|
||||
<FieldRow label="Favorite maqamat" value={selectedUser.favoriteMaqamat} />
|
||||
<FieldRow label="Preferred mood" value={selectedUser.preferredMood} />
|
||||
<FieldRow label="Experience" value={selectedUser.experienceLevel} />
|
||||
<FieldRow label="Instruments" value={selectedUser.favoriteInstruments} />
|
||||
<FieldRow label="Verified" value={selectedUser.isVerified} />
|
||||
<FieldRow label="Disabled" value={selectedUser.isDisabled} />
|
||||
<FieldRow label="Created at" value={formatDateTime(selectedUser.createdAt)} />
|
||||
|
||||
@@ -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 (
|
||||
<Card className="overflow-hidden border-border/70">
|
||||
<CardContent className="p-0">
|
||||
{topText ? (
|
||||
<div className="border-b border-border/70 p-4">
|
||||
<p className="line-clamp-4 text-sm leading-6 text-foreground">{topText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMediaShell ? (
|
||||
<div className="relative aspect-[16/9] border-b border-border/70 bg-secondary/30">
|
||||
{showImagePreview ? (
|
||||
@@ -157,7 +165,7 @@ export function PostPreviewCard({ post }: { post: ApiPost }) {
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-4 text-sm leading-6 text-foreground">
|
||||
{post.content?.trim() || "Media post without caption."}
|
||||
{bottomText || "Media post without caption."}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -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<string, string | undefined>;
|
||||
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<CollaborationRequestItem>;
|
||||
|
||||
export type CollaborationRequestActionResponse = {
|
||||
approved?: boolean;
|
||||
rejected?: boolean;
|
||||
cancelled?: boolean;
|
||||
request: CollaborationRequestItem;
|
||||
};
|
||||
|
||||
export type CollaborationRequestDetailResponse = {
|
||||
request: CollaborationRequestItem;
|
||||
};
|
||||
|
||||
export type SuperAdminCollaborationRequestStatusPayload = {
|
||||
status: Exclude<CollaborationRequestStatus, "cancelled">;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
ffprobe?: Record<string, unknown>;
|
||||
};
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,4 +5,7 @@ export enum MusicRole {
|
||||
LYRICIST = 'lyricist',
|
||||
PRODUCER = 'producer',
|
||||
ARRANGER = 'arranger',
|
||||
TEACHER = 'teacher',
|
||||
STUDENT = 'student',
|
||||
CONTENT_CREATOR = 'content_creator',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
74
src/modules/auth/auth.service.spec.ts
Normal file
74
src/modules/auth/auth.service.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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(
|
||||
userId: string,
|
||||
username: string,
|
||||
|
||||
19
src/modules/auth/dto/change-password.dto.ts
Normal file
19
src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<BlockDocument>) {}
|
||||
@@ -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<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[]> {
|
||||
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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
57
src/modules/blocks/blocks.service.spec.ts
Normal file
57
src/modules/blocks/blocks.service.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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> {
|
||||
this.assertValidTarget(currentUserId, targetUserId);
|
||||
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
|
||||
|
||||
10
src/modules/blocks/dto/block-list-query.dto.ts
Normal file
10
src/modules/blocks/dto/block-list-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 {
|
||||
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, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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 '';
|
||||
|
||||
14
src/modules/metadata/metadata.controller.ts
Normal file
14
src/modules/metadata/metadata.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
10
src/modules/metadata/metadata.module.ts
Normal file
10
src/modules/metadata/metadata.module.ts
Normal file
@@ -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 {}
|
||||
17
src/modules/metadata/metadata.service.spec.ts
Normal file
17
src/modules/metadata/metadata.service.spec.ts
Normal file
@@ -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']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
9
src/modules/metadata/metadata.service.ts
Normal file
9
src/modules/metadata/metadata.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
22
src/modules/metadata/profile-options.constants.ts
Normal file
22
src/modules/metadata/profile-options.constants.ts
Normal file
@@ -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;
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -5,6 +5,13 @@ import { PostsService } from './posts.service';
|
||||
|
||||
const createService = () => {
|
||||
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([]),
|
||||
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<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,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<string, unknown> = {
|
||||
...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<string, unknown> = {};
|
||||
|
||||
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<PostQueryDto, 'postType' | 'mediaType'>): 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 | undefined | null>): 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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
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 '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +293,10 @@ export class UsersService {
|
||||
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
|
||||
}
|
||||
|
||||
async findByIdWithPassword(userId: string): Promise<UserDocument | null> {
|
||||
return this.usersRepository.findByIdWithPassword(userId);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<UserDocument | null> {
|
||||
return this.usersRepository.findOne({ email: email.toLowerCase() });
|
||||
}
|
||||
@@ -327,7 +331,7 @@ export class UsersService {
|
||||
coverImageFile?: UploadedImageFile,
|
||||
): Promise<UserDocument> {
|
||||
const currentUser = await this.findByIdOrFail(userId);
|
||||
const payload: Record<string, unknown> = { ...dto };
|
||||
const payload = await this.prepareManagedUserUpdatePayload(userId, dto);
|
||||
const uploadedImageUrls = await this.attachUploadedProfileImages(
|
||||
payload,
|
||||
avatarFile,
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم