diff --git a/.env.example b/.env.example index 032c9d9..a575a3c 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index a517558..674328e 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -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", diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index f7fa731..8a831f9 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -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", diff --git a/src/modules/users/users.controller.spec.ts b/src/modules/users/users.controller.spec.ts index 1cb29a2..3c94e53 100644 --- a/src/modules/users/users.controller.spec.ts +++ b/src/modules/users/users.controller.spec.ts @@ -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' }); + }); +}); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index b9c400b..ad11aef 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -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') diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index d09d157..6b9152d 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -17,11 +17,13 @@ const createService = (options: { newFollowersThisWeek?: number; newFollowersThisMonth?: number; recentActivity?: Record[]; + 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', + ); + }); +}); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index cfa0df1..a9b4881 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -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 { if (!Types.ObjectId.isValid(currentUserId)) { throw new BadRequestException('Invalid user id'); @@ -964,6 +975,27 @@ export class UsersService { }); } + private buildProfileShareUrl(user: UserDocument | Record): 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('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? ''; + return (configured.trim() || 'https://oudelaa.com').replace(/\/+$/, ''); + } + + private extractUserId(user: UserDocument | Record): string { + const candidate = user.id ?? user._id; + return candidate?.toString?.() ?? String(candidate); + } + private async findTopDashboardContent( authorId: Types.ObjectId, limit: number,