Add report options and profile sharing support

هذا الالتزام موجود في:
boutmoun123
2026-06-11 12:04:51 +03:00
الأصل a3628d19a2
التزام 20fe06b5ed
7 ملفات معدلة مع 186 إضافات و1 حذوفات

عرض الملف

@@ -4,6 +4,7 @@ NODE_ENV=development
PORT=4000
HOST=0.0.0.0
PUBLIC_BASE_URL=http://localhost:4000
PUBLIC_APP_URL=https://oudelaa.com
RESPONSE_ENVELOPE_ENABLED=false
GLOBAL_PREFIX=api/v1
# Add every frontend origin used by web/mobile debug tools.

عرض الملف

@@ -1584,6 +1584,7 @@
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json).to.have.property('stats');",
"pm.expect(json).to.have.property('profileShareUrl');",
"pm.expect(json).to.have.property('contentCounts');",
"pm.expect(json).to.have.property('viewerState');"
]
@@ -1591,6 +1592,34 @@
}
]
},
{
"name": "Get Profile Overview By Username",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/users/by-username/{{username}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json).to.have.property('stats');",
"pm.expect(json).to.have.property('profileShareUrl');",
"pm.expect(json).to.have.property('viewerState');"
]
}
}
]
},
{
"name": "Get My Artist Dashboard",
"request": {
@@ -10251,6 +10280,11 @@
"value": "true",
"type": "string"
},
{
"key": "username",
"value": "artist_one",
"type": "string"
},
{
"key": "reactionType",
"value": "love",

عرض الملف

@@ -1106,6 +1106,7 @@
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json).to.have.property('stats');",
"pm.expect(json).to.have.property('profileShareUrl');",
"pm.expect(json).to.have.property('contentCounts');",
"pm.expect(json).to.have.property('viewerState');"
]
@@ -1113,6 +1114,34 @@
}
]
},
{
"name": "Get Profile Overview By Username",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/users/by-username/{{username}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json).to.have.property('stats');",
"pm.expect(json).to.have.property('profileShareUrl');",
"pm.expect(json).to.have.property('viewerState');"
]
}
}
]
},
{
"name": "Get My Artist Dashboard",
"request": {
@@ -7578,6 +7607,11 @@
"value": "true",
"type": "string"
},
{
"key": "username",
"value": "artist_one",
"type": "string"
},
{
"key": "reactionType",
"value": "love",

عرض الملف

@@ -13,3 +13,22 @@ describe('UsersController artist dashboard', () => {
expect(result).toEqual({ profile: { _id: 'user-1' } });
});
});
describe('UsersController profile lookup', () => {
it('uses the authenticated user id when loading profile overview by username', async () => {
const usersService = {
getProfileOverviewByUsername: jest.fn().mockResolvedValue({
profileShareUrl: 'https://oudelaa.com/u/artist',
}),
};
const controller = new UsersController(usersService as any);
const result = await controller.getProfileOverviewByUsername(
{ sub: 'viewer-1' } as any,
'artist',
);
expect(usersService.getProfileOverviewByUsername).toHaveBeenCalledWith('artist', 'viewer-1');
expect(result).toEqual({ profileShareUrl: 'https://oudelaa.com/u/artist' });
});
});

عرض الملف

@@ -288,6 +288,16 @@ export class UsersController {
return this.usersService.getMyDashboard(user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('by-username/:username')
async getProfileOverviewByUsername(
@CurrentUser() user: JwtPayload,
@Param('username') username: string,
) {
return this.usersService.getProfileOverviewByUsername(username, user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/profile-overview')

عرض الملف

@@ -17,11 +17,13 @@ const createService = (options: {
newFollowersThisWeek?: number;
newFollowersThisMonth?: number;
recentActivity?: Record<string, any>[];
publicAppUrl?: string;
} = {}) => {
const userId = new Types.ObjectId().toString();
const user = options.user ?? {
_id: new Types.ObjectId(userId),
name: 'Artist',
username: 'artist_one',
stageName: 'Stage',
bio: 'Bio',
avatar: '',
@@ -34,6 +36,7 @@ const createService = (options: {
return {
_id: userId,
name: this.name,
username: this.username,
stageName: this.stageName,
bio: this.bio,
avatar: this.avatar,
@@ -83,12 +86,13 @@ const createService = (options: {
};
const usersRepository = {
findById: jest.fn().mockResolvedValue(user),
findOne: jest.fn().mockResolvedValue(user),
};
const service = new UsersService(
connection as any,
usersRepository as any,
{ logSuperAdminAction: jest.fn() } as any,
{ get: jest.fn() } as any,
{ get: jest.fn((key: string) => (key === 'PUBLIC_APP_URL' ? options.publicAppUrl : undefined)) } as any,
{ saveFile: jest.fn(), deleteFile: jest.fn() } as any,
);
@@ -183,3 +187,54 @@ describe('UsersService artist dashboard', () => {
);
});
});
describe('UsersService profile sharing', () => {
it('returns profileShareUrl with username in profile overview', async () => {
const { service, userId } = createService({ publicAppUrl: 'https://oudelaa.com/' });
const result = await service.getProfileOverview(userId, userId);
expect(result.profileShareUrl).toBe('https://oudelaa.com/u/artist_one');
});
it('falls back to profile id when username is missing', async () => {
const userId = new Types.ObjectId().toString();
const { service } = createService({
user: {
_id: new Types.ObjectId(userId),
name: 'Artist',
username: '',
followersCount: 0,
followingCount: 0,
postsCount: 0,
isDisabled: false,
},
publicAppUrl: 'https://oudelaa.com',
});
const result = await service.getProfileOverview(userId, userId);
expect(result.profileShareUrl).toBe(`https://oudelaa.com/profile/${userId}`);
});
it('returns profile overview by username', async () => {
const { service, userId, usersRepository } = createService({
publicAppUrl: 'https://oudelaa.com',
});
const result = await service.getProfileOverviewByUsername('Artist_One', userId);
expect(usersRepository.findOne).toHaveBeenCalledWith({ username: 'artist_one' });
expect(usersRepository.findById).toHaveBeenCalledWith(userId);
expect(result.profileShareUrl).toBe('https://oudelaa.com/u/artist_one');
});
it('throws not found for missing username', async () => {
const { service, usersRepository, userId } = createService();
usersRepository.findOne.mockResolvedValueOnce(null);
await expect(service.getProfileOverviewByUsername('missing', userId)).rejects.toThrow(
'User not found',
);
});
});

عرض الملف

@@ -759,6 +759,7 @@ export class UsersService {
return {
user,
profileShareUrl: this.buildProfileShareUrl(user),
stats: {
followersCount: user.followersCount ?? 0,
followingCount: user.followingCount ?? 0,
@@ -799,6 +800,16 @@ export class UsersService {
};
}
async getProfileOverviewByUsername(username: string, viewerUserId: string) {
const normalizedUsername = username.trim().toLowerCase();
const user = normalizedUsername ? await this.findByUsername(normalizedUsername) : null;
if (!user || user.isDisabled) {
throw new NotFoundException('User not found');
}
return this.getProfileOverview(this.extractUserId(user), viewerUserId);
}
async getMyDashboard(currentUserId: string): Promise<ArtistDashboardResponse> {
if (!Types.ObjectId.isValid(currentUserId)) {
throw new BadRequestException('Invalid user id');
@@ -964,6 +975,27 @@ export class UsersService {
});
}
private buildProfileShareUrl(user: UserDocument | Record<string, any>): string {
const appUrl = this.resolvePublicAppUrl();
const username = typeof user.username === 'string' ? user.username.trim() : '';
if (username) {
return `${appUrl}/u/${encodeURIComponent(username)}`;
}
return `${appUrl}/profile/${this.extractUserId(user)}`;
}
private resolvePublicAppUrl(): string {
const configured =
this.configService.get<string>('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';
return (configured.trim() || 'https://oudelaa.com').replace(/\/+$/, '');
}
private extractUserId(user: UserDocument | Record<string, any>): string {
const candidate = user.id ?? user._id;
return candidate?.toString?.() ?? String(candidate);
}
private async findTopDashboardContent(
authorId: Types.ObjectId,
limit: number,