Normalize audio waveform peaks for Flutter
Deploy To Ghaymah / deploy (push) Has been cancelled

This commit is contained in:
boutmoun123
2026-06-08 01:43:13 +03:00
parent 8a3414db72
commit 87cd42b706
9 changed files with 2247 additions and 0 deletions
@@ -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' } });
});
});
+7
View File
@@ -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')
+185
View File
@@ -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,
);
});
});
+291
View File
@@ -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) {