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 PORT=4000
HOST=0.0.0.0 HOST=0.0.0.0
PUBLIC_BASE_URL=http://localhost:4000 PUBLIC_BASE_URL=http://localhost:4000
PUBLIC_APP_URL=https://oudelaa.com
RESPONSE_ENVELOPE_ENABLED=false RESPONSE_ENVELOPE_ENABLED=false
GLOBAL_PREFIX=api/v1 GLOBAL_PREFIX=api/v1
# Add every frontend origin used by web/mobile debug tools. # 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); });", "pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();", "const json = pm.response.json();",
"pm.expect(json).to.have.property('stats');", "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('contentCounts');",
"pm.expect(json).to.have.property('viewerState');" "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", "name": "Get My Artist Dashboard",
"request": { "request": {
@@ -10251,6 +10280,11 @@
"value": "true", "value": "true",
"type": "string" "type": "string"
}, },
{
"key": "username",
"value": "artist_one",
"type": "string"
},
{ {
"key": "reactionType", "key": "reactionType",
"value": "love", "value": "love",

عرض الملف

@@ -1106,6 +1106,7 @@
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });", "pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();", "const json = pm.response.json();",
"pm.expect(json).to.have.property('stats');", "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('contentCounts');",
"pm.expect(json).to.have.property('viewerState');" "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", "name": "Get My Artist Dashboard",
"request": { "request": {
@@ -7578,6 +7607,11 @@
"value": "true", "value": "true",
"type": "string" "type": "string"
}, },
{
"key": "username",
"value": "artist_one",
"type": "string"
},
{ {
"key": "reactionType", "key": "reactionType",
"value": "love", "value": "love",

عرض الملف

@@ -13,3 +13,22 @@ describe('UsersController artist dashboard', () => {
expect(result).toEqual({ profile: { _id: 'user-1' } }); 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); 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() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get(':id/profile-overview') @Get(':id/profile-overview')

عرض الملف

@@ -17,11 +17,13 @@ const createService = (options: {
newFollowersThisWeek?: number; newFollowersThisWeek?: number;
newFollowersThisMonth?: number; newFollowersThisMonth?: number;
recentActivity?: Record<string, any>[]; recentActivity?: Record<string, any>[];
publicAppUrl?: string;
} = {}) => { } = {}) => {
const userId = new Types.ObjectId().toString(); const userId = new Types.ObjectId().toString();
const user = options.user ?? { const user = options.user ?? {
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
name: 'Artist', name: 'Artist',
username: 'artist_one',
stageName: 'Stage', stageName: 'Stage',
bio: 'Bio', bio: 'Bio',
avatar: '', avatar: '',
@@ -34,6 +36,7 @@ const createService = (options: {
return { return {
_id: userId, _id: userId,
name: this.name, name: this.name,
username: this.username,
stageName: this.stageName, stageName: this.stageName,
bio: this.bio, bio: this.bio,
avatar: this.avatar, avatar: this.avatar,
@@ -83,12 +86,13 @@ const createService = (options: {
}; };
const usersRepository = { const usersRepository = {
findById: jest.fn().mockResolvedValue(user), findById: jest.fn().mockResolvedValue(user),
findOne: jest.fn().mockResolvedValue(user),
}; };
const service = new UsersService( const service = new UsersService(
connection as any, connection as any,
usersRepository as any, usersRepository as any,
{ logSuperAdminAction: jest.fn() } 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, { 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 { return {
user, user,
profileShareUrl: this.buildProfileShareUrl(user),
stats: { stats: {
followersCount: user.followersCount ?? 0, followersCount: user.followersCount ?? 0,
followingCount: user.followingCount ?? 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> { async getMyDashboard(currentUserId: string): Promise<ArtistDashboardResponse> {
if (!Types.ObjectId.isValid(currentUserId)) { if (!Types.ObjectId.isValid(currentUserId)) {
throw new BadRequestException('Invalid user id'); 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( private async findTopDashboardContent(
authorId: Types.ObjectId, authorId: Types.ObjectId,
limit: number, limit: number,