Add report options and profile sharing support
هذا الالتزام موجود في:
@@ -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,
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم