This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
export type ArtistDashboardProfile = {
|
||||
_id: string;
|
||||
coverImage: string;
|
||||
avatar: string;
|
||||
name: string;
|
||||
stageName: string;
|
||||
bio: string;
|
||||
isVerified: boolean;
|
||||
};
|
||||
|
||||
export type ArtistDashboardStats = {
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
postsCount: number;
|
||||
collaborationsCount: number;
|
||||
viewCount: number;
|
||||
playCount: number;
|
||||
listenCount: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
savesCount: number;
|
||||
sharesCount: number;
|
||||
engagementRate: number;
|
||||
scorePercentage: number;
|
||||
earnings: number;
|
||||
};
|
||||
|
||||
export type ArtistDashboardAudience = {
|
||||
followers: number;
|
||||
nonFollowers: number;
|
||||
newFollowersThisWeek: number;
|
||||
newFollowersThisMonth: number;
|
||||
};
|
||||
|
||||
export type ArtistDashboardTopContentItem = {
|
||||
_id: string;
|
||||
postType: string;
|
||||
thumbnailUrl: string;
|
||||
displayUrl: string;
|
||||
content: string;
|
||||
viewCount: number;
|
||||
playCount: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
savesCount: number;
|
||||
engagementScore: number;
|
||||
createdAt: string | Date;
|
||||
};
|
||||
|
||||
export type ArtistDashboardRecentActivityItem = {
|
||||
_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
previewText: string;
|
||||
resourceType: string;
|
||||
referenceId: string | null;
|
||||
deepLink: string;
|
||||
createdAt: string | Date;
|
||||
};
|
||||
|
||||
export type ArtistDashboardMeta = {
|
||||
generatedAt: string;
|
||||
chartAvailable: boolean;
|
||||
earningsAvailable: boolean;
|
||||
audienceAnalyticsAvailable: boolean;
|
||||
};
|
||||
|
||||
export type ArtistDashboardResponse = {
|
||||
profile: ArtistDashboardProfile;
|
||||
stats: ArtistDashboardStats;
|
||||
audience: ArtistDashboardAudience;
|
||||
engagementChart: unknown[];
|
||||
topContent: ArtistDashboardTopContentItem[];
|
||||
recentActivity: ArtistDashboardRecentActivityItem[];
|
||||
meta: ArtistDashboardMeta;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
describe('UsersController artist dashboard', () => {
|
||||
it('uses the authenticated user id for the private dashboard endpoint', async () => {
|
||||
const usersService = {
|
||||
getMyDashboard: jest.fn().mockResolvedValue({ profile: { _id: 'user-1' } }),
|
||||
};
|
||||
const controller = new UsersController(usersService as any);
|
||||
|
||||
const result = await controller.getMyDashboard({ sub: 'user-1' } as any);
|
||||
|
||||
expect(usersService.getMyDashboard).toHaveBeenCalledWith('user-1');
|
||||
expect(result).toEqual({ profile: { _id: 'user-1' } });
|
||||
});
|
||||
});
|
||||
@@ -281,6 +281,13 @@ export class UsersController {
|
||||
return this.usersService.discoverTalents(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me/dashboard')
|
||||
async getMyDashboard(@CurrentUser() user: JwtPayload) {
|
||||
return this.usersService.getMyDashboard(user.sub);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':id/profile-overview')
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
const chain = <T>(value: T) => ({
|
||||
sort: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
project: jest.fn().mockReturnThis(),
|
||||
toArray: jest.fn().mockResolvedValue(value),
|
||||
});
|
||||
|
||||
const createService = (options: {
|
||||
user?: Record<string, any> | null;
|
||||
postStats?: Record<string, number>;
|
||||
topContent?: Record<string, any>[];
|
||||
collaborationsCount?: number;
|
||||
newFollowersThisWeek?: number;
|
||||
newFollowersThisMonth?: number;
|
||||
recentActivity?: Record<string, any>[];
|
||||
} = {}) => {
|
||||
const userId = new Types.ObjectId().toString();
|
||||
const user = options.user ?? {
|
||||
_id: new Types.ObjectId(userId),
|
||||
name: 'Artist',
|
||||
stageName: 'Stage',
|
||||
bio: 'Bio',
|
||||
avatar: '',
|
||||
coverImage: '',
|
||||
followersCount: 10,
|
||||
followingCount: 4,
|
||||
isVerified: true,
|
||||
isDisabled: false,
|
||||
toObject() {
|
||||
return {
|
||||
_id: userId,
|
||||
name: this.name,
|
||||
stageName: this.stageName,
|
||||
bio: this.bio,
|
||||
avatar: this.avatar,
|
||||
coverImage: this.coverImage,
|
||||
followersCount: this.followersCount,
|
||||
followingCount: this.followingCount,
|
||||
isVerified: this.isVerified,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const postsAggregate = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
toArray: jest.fn().mockResolvedValue(options.postStats ? [options.postStats] : []),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
toArray: jest.fn().mockResolvedValue(options.topContent ?? []),
|
||||
});
|
||||
|
||||
const postsCollection = {
|
||||
aggregate: postsAggregate,
|
||||
countDocuments: jest.fn().mockResolvedValue(options.collaborationsCount ?? 0),
|
||||
};
|
||||
const followsCollection = {
|
||||
countDocuments: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(options.newFollowersThisWeek ?? 0)
|
||||
.mockResolvedValueOnce(options.newFollowersThisMonth ?? 0),
|
||||
};
|
||||
const notificationsCollection = {
|
||||
find: jest.fn().mockReturnValue(chain(options.recentActivity ?? [])),
|
||||
};
|
||||
const connection = {
|
||||
collection: jest.fn((name: string) => {
|
||||
if (name === 'posts') {
|
||||
return postsCollection;
|
||||
}
|
||||
if (name === 'follows') {
|
||||
return followsCollection;
|
||||
}
|
||||
if (name === 'notifications') {
|
||||
return notificationsCollection;
|
||||
}
|
||||
throw new Error(`Unexpected collection ${name}`);
|
||||
}),
|
||||
};
|
||||
const usersRepository = {
|
||||
findById: jest.fn().mockResolvedValue(user),
|
||||
};
|
||||
const service = new UsersService(
|
||||
connection as any,
|
||||
usersRepository as any,
|
||||
{ logSuperAdminAction: jest.fn() } as any,
|
||||
{ get: jest.fn() } as any,
|
||||
{ saveFile: jest.fn(), deleteFile: jest.fn() } as any,
|
||||
);
|
||||
|
||||
return { service, userId, usersRepository, postsCollection, followsCollection };
|
||||
};
|
||||
|
||||
describe('UsersService artist dashboard', () => {
|
||||
it('returns zeros and safe empty arrays for an authenticated user with no posts', async () => {
|
||||
const { service, userId } = createService();
|
||||
|
||||
const result = await service.getMyDashboard(userId);
|
||||
|
||||
expect(result.profile._id).toBe(userId);
|
||||
expect(result.stats.postsCount).toBe(0);
|
||||
expect(result.stats.viewCount).toBe(0);
|
||||
expect(result.stats.playCount).toBe(0);
|
||||
expect(result.stats.listenCount).toBe(0);
|
||||
expect(result.stats.engagementRate).toBe(0);
|
||||
expect(result.stats.earnings).toBe(0);
|
||||
expect(result.stats.scorePercentage).toBe(0);
|
||||
expect(result.audience.followers).toBe(10);
|
||||
expect(result.audience.nonFollowers).toBe(0);
|
||||
expect(result.engagementChart).toEqual([]);
|
||||
expect(result.topContent).toEqual([]);
|
||||
expect(result.meta.chartAvailable).toBe(false);
|
||||
expect(result.meta.earningsAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('aggregates owned posts and calculates engagement rate safely', async () => {
|
||||
const postId = new Types.ObjectId();
|
||||
const { service, userId } = createService({
|
||||
postStats: {
|
||||
postsCount: 2,
|
||||
viewCount: 100,
|
||||
playCount: 50,
|
||||
likesCount: 12,
|
||||
commentsCount: 3,
|
||||
savesCount: 2,
|
||||
sharesCount: 1,
|
||||
},
|
||||
topContent: [
|
||||
{
|
||||
_id: postId,
|
||||
postType: 'audio',
|
||||
content: 'Track',
|
||||
thumbnailUrl: '',
|
||||
audioUrl: '/uploads/posts/audios/audio.mp3',
|
||||
viewCount: 100,
|
||||
playCount: 50,
|
||||
likesCount: 12,
|
||||
commentsCount: 3,
|
||||
savesCount: 2,
|
||||
engagementScore: 18,
|
||||
createdAt: new Date('2026-06-08T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
collaborationsCount: 2,
|
||||
newFollowersThisWeek: 1,
|
||||
newFollowersThisMonth: 3,
|
||||
});
|
||||
|
||||
const result = await service.getMyDashboard(userId);
|
||||
|
||||
expect(result.stats.postsCount).toBe(2);
|
||||
expect(result.stats.viewCount).toBe(100);
|
||||
expect(result.stats.playCount).toBe(50);
|
||||
expect(result.stats.listenCount).toBe(50);
|
||||
expect(result.stats.likesCount).toBe(12);
|
||||
expect(result.stats.commentsCount).toBe(3);
|
||||
expect(result.stats.savesCount).toBe(2);
|
||||
expect(result.stats.sharesCount).toBe(1);
|
||||
expect(result.stats.collaborationsCount).toBe(2);
|
||||
expect(result.stats.engagementRate).toBe(12);
|
||||
expect(result.audience.newFollowersThisWeek).toBe(1);
|
||||
expect(result.audience.newFollowersThisMonth).toBe(3);
|
||||
expect(result.topContent).toHaveLength(1);
|
||||
expect(result.topContent[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
_id: postId.toString(),
|
||||
postType: 'audio',
|
||||
content: 'Track',
|
||||
engagementScore: 18,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid current user ids', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.getMyDashboard('not-object-id')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,12 +13,18 @@ import { ExperienceLevel } from '../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../common/enums/music-role.enum';
|
||||
import { hashValue } from '../../common/utils/hash.util';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UserRole } from '../../common/enums/user-role.enum';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { AdminDisableUserDto } from './dto/admin-disable-user.dto';
|
||||
import {
|
||||
ArtistDashboardRecentActivityItem,
|
||||
ArtistDashboardResponse,
|
||||
ArtistDashboardTopContentItem,
|
||||
} from './dto/artist-dashboard-response.dto';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { MusicSetupDto } from './dto/music-setup.dto';
|
||||
import { ProfileSetupDto } from './dto/profile-setup.dto';
|
||||
@@ -36,6 +42,16 @@ type UploadedImageFile = {
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
type UserPostStats = {
|
||||
postsCount: number;
|
||||
viewCount: number;
|
||||
playCount: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
savesCount: number;
|
||||
sharesCount: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@@ -790,6 +806,94 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async getMyDashboard(currentUserId: string): Promise<ArtistDashboardResponse> {
|
||||
if (!Types.ObjectId.isValid(currentUserId)) {
|
||||
throw new BadRequestException('Invalid user id');
|
||||
}
|
||||
|
||||
const user = await this.findByIdOrFail(currentUserId);
|
||||
if (user.isDisabled) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const objectUserId = new Types.ObjectId(currentUserId);
|
||||
const now = new Date();
|
||||
const weekAgo = new Date(now);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const monthAgo = new Date(now);
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
|
||||
const [postStats, collaborationsCount, topContent, recentActivity, newFollowersThisWeek, newFollowersThisMonth] =
|
||||
await Promise.all([
|
||||
this.aggregateUserPostStats(objectUserId),
|
||||
this.countUserCollaborations(objectUserId),
|
||||
this.findTopDashboardContent(objectUserId, 5),
|
||||
this.findArtistRecentActivity(objectUserId, 10),
|
||||
this.connection.collection('follows').countDocuments({
|
||||
followingId: objectUserId,
|
||||
createdAt: { $gte: weekAgo },
|
||||
}),
|
||||
this.connection.collection('follows').countDocuments({
|
||||
followingId: objectUserId,
|
||||
createdAt: { $gte: monthAgo },
|
||||
}),
|
||||
]);
|
||||
|
||||
const followersCount = this.toSafeNumber(user.followersCount);
|
||||
const followingCount = this.toSafeNumber(user.followingCount);
|
||||
const totalEngagement =
|
||||
postStats.likesCount + postStats.commentsCount + postStats.savesCount + postStats.sharesCount;
|
||||
const totalReach = postStats.viewCount + postStats.playCount;
|
||||
const engagementRate = this.roundPercent((totalEngagement / Math.max(totalReach, 1)) * 100);
|
||||
const userObject = typeof user.toObject === 'function' ? (user.toObject() as Record<string, any>) : (user as any);
|
||||
|
||||
return {
|
||||
profile: {
|
||||
_id: currentUserId,
|
||||
coverImage: userObject.coverImage ?? '',
|
||||
avatar: userObject.avatar ?? '',
|
||||
name: userObject.name ?? '',
|
||||
stageName: userObject.stageName ?? '',
|
||||
bio: userObject.bio ?? '',
|
||||
isVerified: userObject.isVerified ?? false,
|
||||
},
|
||||
stats: {
|
||||
followersCount,
|
||||
followingCount,
|
||||
postsCount: postStats.postsCount,
|
||||
collaborationsCount,
|
||||
viewCount: postStats.viewCount,
|
||||
playCount: postStats.playCount,
|
||||
listenCount: postStats.playCount,
|
||||
likesCount: postStats.likesCount,
|
||||
commentsCount: postStats.commentsCount,
|
||||
savesCount: postStats.savesCount,
|
||||
sharesCount: postStats.sharesCount,
|
||||
engagementRate,
|
||||
// TODO: Replace with real artist score logic when product scoring rules exist.
|
||||
scorePercentage: 0,
|
||||
// TODO: Replace with real monetization calculation when earnings module exists.
|
||||
earnings: 0,
|
||||
},
|
||||
audience: {
|
||||
followers: followersCount,
|
||||
// TODO: Requires viewer-level analytics/events to calculate non-followers.
|
||||
nonFollowers: 0,
|
||||
newFollowersThisWeek,
|
||||
newFollowersThisMonth,
|
||||
},
|
||||
engagementChart: [],
|
||||
topContent,
|
||||
recentActivity,
|
||||
meta: {
|
||||
generatedAt: now.toISOString(),
|
||||
chartAvailable: false,
|
||||
earningsAvailable: false,
|
||||
audienceAnalyticsAvailable: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async searchUsersForSuperAdmin(query: UserQueryDto): Promise<{
|
||||
items: UserDocument[];
|
||||
page: number;
|
||||
@@ -819,6 +923,193 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
private async aggregateUserPostStats(authorId: Types.ObjectId): Promise<UserPostStats> {
|
||||
const [row] = await this.connection
|
||||
.collection('posts')
|
||||
.aggregate<UserPostStats>([
|
||||
{
|
||||
$match: {
|
||||
authorId,
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
postsCount: { $sum: 1 },
|
||||
viewCount: { $sum: { $ifNull: ['$viewCount', 0] } },
|
||||
playCount: { $sum: { $ifNull: ['$playCount', 0] } },
|
||||
likesCount: { $sum: { $ifNull: ['$likesCount', 0] } },
|
||||
commentsCount: { $sum: { $ifNull: ['$commentsCount', 0] } },
|
||||
savesCount: { $sum: { $ifNull: ['$savesCount', 0] } },
|
||||
sharesCount: { $sum: { $ifNull: ['$shareCount', 0] } },
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
return {
|
||||
postsCount: this.toSafeNumber(row?.postsCount),
|
||||
viewCount: this.toSafeNumber(row?.viewCount),
|
||||
playCount: this.toSafeNumber(row?.playCount),
|
||||
likesCount: this.toSafeNumber(row?.likesCount),
|
||||
commentsCount: this.toSafeNumber(row?.commentsCount),
|
||||
savesCount: this.toSafeNumber(row?.savesCount),
|
||||
sharesCount: this.toSafeNumber(row?.sharesCount),
|
||||
};
|
||||
}
|
||||
|
||||
private async countUserCollaborations(userId: Types.ObjectId): Promise<number> {
|
||||
return this.connection.collection('posts').countDocuments({
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
$or: [
|
||||
{ authorId: userId, 'taggedUserIds.0': { $exists: true } },
|
||||
{ authorId: { $ne: userId }, taggedUserIds: userId },
|
||||
{ collaboratorIds: userId },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async findTopDashboardContent(
|
||||
authorId: Types.ObjectId,
|
||||
limit: number,
|
||||
): Promise<ArtistDashboardTopContentItem[]> {
|
||||
const rows = await this.connection
|
||||
.collection('posts')
|
||||
.aggregate<Record<string, any>>([
|
||||
{
|
||||
$match: {
|
||||
authorId,
|
||||
isDeleted: { $ne: true },
|
||||
isArchived: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
engagementScore: {
|
||||
$add: [
|
||||
{ $ifNull: ['$likesCount', 0] },
|
||||
{ $ifNull: ['$commentsCount', 0] },
|
||||
{ $ifNull: ['$savesCount', 0] },
|
||||
{ $ifNull: ['$shareCount', 0] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { engagementScore: -1, viewCount: -1, playCount: -1, createdAt: -1 } },
|
||||
{ $limit: Math.max(1, limit) },
|
||||
{
|
||||
$project: {
|
||||
postType: 1,
|
||||
thumbnailUrl: 1,
|
||||
imageUrls: 1,
|
||||
imageItems: 1,
|
||||
videoUrl: 1,
|
||||
audioUrl: 1,
|
||||
content: 1,
|
||||
viewCount: 1,
|
||||
playCount: 1,
|
||||
likesCount: 1,
|
||||
commentsCount: 1,
|
||||
savesCount: 1,
|
||||
engagementScore: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
},
|
||||
])
|
||||
.toArray();
|
||||
|
||||
return rows.map((post) => {
|
||||
const thumbnailUrl = this.resolveManagedStringUrl(post.thumbnailUrl);
|
||||
const fallbackDisplayUrl = this.resolveTopContentDisplayUrl(post);
|
||||
return {
|
||||
_id: post._id?.toString?.() ?? '',
|
||||
postType: post.postType ?? '',
|
||||
thumbnailUrl,
|
||||
displayUrl: thumbnailUrl || fallbackDisplayUrl,
|
||||
content: post.content ?? '',
|
||||
viewCount: this.toSafeNumber(post.viewCount),
|
||||
playCount: this.toSafeNumber(post.playCount),
|
||||
likesCount: this.toSafeNumber(post.likesCount),
|
||||
commentsCount: this.toSafeNumber(post.commentsCount),
|
||||
savesCount: this.toSafeNumber(post.savesCount),
|
||||
engagementScore: this.toSafeNumber(post.engagementScore),
|
||||
createdAt: post.createdAt ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async findArtistRecentActivity(
|
||||
userId: Types.ObjectId,
|
||||
limit: number,
|
||||
): Promise<ArtistDashboardRecentActivityItem[]> {
|
||||
const rows = await this.connection
|
||||
.collection('notifications')
|
||||
.find({
|
||||
$or: [{ recipientId: userId }, { actorId: userId }],
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(Math.max(1, limit))
|
||||
.project({
|
||||
type: 1,
|
||||
title: 1,
|
||||
previewText: 1,
|
||||
resourceType: 1,
|
||||
referenceId: 1,
|
||||
deepLink: 1,
|
||||
createdAt: 1,
|
||||
})
|
||||
.toArray();
|
||||
|
||||
return rows.map((activity) => ({
|
||||
_id: activity._id?.toString?.() ?? '',
|
||||
type: activity.type ?? '',
|
||||
title: activity.title ?? '',
|
||||
previewText: activity.previewText ?? '',
|
||||
resourceType: activity.resourceType ?? '',
|
||||
referenceId: activity.referenceId?.toString?.() ?? null,
|
||||
deepLink: activity.deepLink ?? '',
|
||||
createdAt: activity.createdAt ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
private resolveTopContentDisplayUrl(post: Record<string, any>): string {
|
||||
const imageItems = Array.isArray(post.imageItems) ? post.imageItems : [];
|
||||
const imageUrls = Array.isArray(post.imageUrls) ? post.imageUrls : [];
|
||||
const candidate =
|
||||
imageItems.find((item) => typeof item?.url === 'string' && item.url)?.url ??
|
||||
imageUrls.find((url) => typeof url === 'string' && url) ??
|
||||
post.videoUrl ??
|
||||
post.audioUrl ??
|
||||
'';
|
||||
|
||||
return this.resolveManagedStringUrl(candidate);
|
||||
}
|
||||
|
||||
private resolveManagedStringUrl(value: unknown): string {
|
||||
const resolved = resolveManagedFileUrl(typeof value === 'string' ? value : '');
|
||||
return typeof resolved === 'string' ? resolved : '';
|
||||
}
|
||||
|
||||
private toSafeNumber(value: unknown): number {
|
||||
const numeric = Number(value ?? 0);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
|
||||
}
|
||||
|
||||
private roundPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
private async assertTargetIsAdmin(userId: string): Promise<UserDocument> {
|
||||
const user = await this.findByIdOrFail(userId);
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
|
||||
Reference in New Issue
Block a user