From 370757f52bc2d3097cb9f76de00e4e582eb470ce Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 11:15:10 -0300 Subject: [PATCH 01/56] refactor: reorganize imports and update endpoint routes in UserController --- server/src/user/user.controller.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 6e13d102..9087b65c 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,31 +1,28 @@ -import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Inject, Get, Patch, Body, Query } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { GetRequestToken, validateUser } from '@server/GetRequestUser'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; - -import { GetRequestToken, validateUser } from '@server/GetRequestUser'; +import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; -import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; @Controller('user') +@ApiTags('user') +@ApiBearerAuth() export class UserController { constructor( @Inject(UserService) private readonly userService: UserService, ) {} - @Get() - @ApiTags('user') - @ApiBearerAuth() + @Get('by-query') async getUser(@Query() query: GetUser) { return await this.userService.getUserByEmailOrId(query); } - @Get() - @ApiTags('user') - @ApiBearerAuth() + @Get('paginated') async getUserPaginated(@Query() query: PageQueryDTO) { return await this.userService.getUserPaginated(query); } From 71c15bd3061b8df56c88242fbf311e3ff2dfa6aa Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 12:03:08 -0300 Subject: [PATCH 02/56] feat: add UpdateUserProfile DTO and validation for social media links --- .../user/dto/UpdateUserProfile.dto.spec.ts | 62 +++++++ .../user/dto/UpdateUserProfile.dto.ts | 158 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 shared/validation/user/dto/UpdateUserProfile.dto.spec.ts create mode 100644 shared/validation/user/dto/UpdateUserProfile.dto.ts diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts new file mode 100644 index 00000000..873ae318 --- /dev/null +++ b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts @@ -0,0 +1,62 @@ +import { validate } from 'class-validator'; +import { UserLinks } from './UpdateUserProfile.dto'; + +describe('UpdateUserProfileDto', () => { + describe('UserLinks', () => { + it('should validate valid URLs', async () => { + const userLinks = new UserLinks(); + + userLinks.github = 'https://github.com/tomast1337'; + userLinks.youtube = 'https://www.youtube.com/@Bentroen_'; + userLinks.spotify = + 'https://open.spotify.com/artist/1McMsnEElThX1knmY4oliG?si=v95i3XbRRgKT9JwyiFiFEg'; + userLinks.bandcamp = 'https://igorrr.bandcamp.com/'; + userLinks.facebook = 'https://www.facebook.com/MrBean'; + userLinks.reddit = 'https://www.reddit.com/user/Unidan/'; + userLinks.soundcloud = 'https://soundcloud.com/futureisnow'; + userLinks.steam = 'https://steamcommunity.com/id/CattleDecapitation/'; + userLinks.x = 'https://x.com/Trail_Cams'; + userLinks.twitch = 'https://www.twitch.tv/vinesauce'; + userLinks.threads = 'https://www.threads.net/@kimkardashian'; + userLinks.tiktok = 'https://www.tiktok.com/@karolg'; + userLinks.snapchat = 'https://www.snapchat.com/add/username'; + userLinks.instagram = 'https://instagram.com/validuser'; + userLinks.discord = 'https://discord.com/validuser'; + userLinks.telegram = 'https://t.me/validuser'; + + const errors = await validate(userLinks); + console.log(errors); + expect(errors.length).toBe(0); + }); + + it('should invalidate invalid URLs', async () => { + const userLinks = new UserLinks(); + userLinks.bandcamp = 'invalid-url'; + userLinks.discord = 'invalid-url'; + userLinks.facebook = 'invalid-url'; + userLinks.github = 'invalid-url'; + userLinks.instagram = 'invalid-url'; + userLinks.reddit = 'invalid-url'; + userLinks.snapchat = 'invalid-url'; + userLinks.soundcloud = 'invalid-url'; + userLinks.spotify = 'invalid-url'; + userLinks.steam = 'invalid-url'; + userLinks.telegram = 'invalid-url'; + userLinks.tiktok = 'invalid-url'; + userLinks.threads = 'invalid-url'; + userLinks.twitch = 'invalid-url'; + userLinks.x = 'invalid-url'; + userLinks.youtube = 'invalid-url'; + + const errors = await validate(userLinks); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should allow optional fields to be empty', async () => { + const userLinks = new UserLinks(); + + const errors = await validate(userLinks); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.ts b/shared/validation/user/dto/UpdateUserProfile.dto.ts new file mode 100644 index 00000000..37ebbfee --- /dev/null +++ b/shared/validation/user/dto/UpdateUserProfile.dto.ts @@ -0,0 +1,158 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export const LinkRegexes = { + bandcamp: /https?:\/\/[a-zA-Z0-9_-]+\.bandcamp\.com\/?/, + discord: /https?:\/\/(www\.)?discord\.com\/[a-zA-Z0-9_]+/, + facebook: /https?:\/\/(www\.)?facebook\.com\/[a-zA-Z0-9_]+/, + github: /https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_-]+/, + instagram: /https?:\/\/(www\.)?instagram\.com\/[a-zA-Z0-9_]+/, + reddit: /https?:\/\/(www\.)?reddit\.com\/user\/[a-zA-Z0-9_-]+/, + snapchat: /https?:\/\/(www\.)?snapchat\.com\/add\/[a-zA-Z0-9_-]+/, + soundcloud: /https?:\/\/(www\.)?soundcloud\.com\/[a-zA-Z0-9_-]+/, + spotify: /https?:\/\/open\.spotify\.com\/artist\/[a-zA-Z0-9?&=]+/, + steam: /https?:\/\/steamcommunity\.com\/id\/[a-zA-Z0-9_-]+/, + telegram: /https?:\/\/(www\.)?t\.me\/[a-zA-Z0-9_]+/, + tiktok: /https?:\/\/(www\.)?tiktok\.com\/@?[a-zA-Z0-9_]+/, + threads: /https?:\/\/(www\.)?threads\.net\/@?[a-zA-Z0-9_]+/, + twitch: /https?:\/\/(www\.)?twitch\.tv\/[a-zA-Z0-9_]+/, + x: /https?:\/\/(www\.)?x\.com\/[a-zA-Z0-9_]+/, + youtube: /https?:\/\/(www\.)?youtube\.com\/@?[a-zA-Z0-9_-]+/, +}; + +export class UserLinks { + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.bandcamp) + bandcamp?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.discord) + discord?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.facebook) + facebook?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.github) + github?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.instagram) + instagram?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.reddit) + reddit?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.snapchat) + snapchat?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.soundcloud) + soundcloud?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.spotify) + spotify?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.steam) + steam?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.telegram) + telegram?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.tiktok) + tiktok?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.threads) + threads?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.twitch) + twitch?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.x) + x?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.youtube) + youtube?: string; +} + +export class UpdateUserProfileDto { + @IsString() + @MaxLength(64) + @MinLength(3) + @IsOptional() + @ApiProperty({ + description: 'Username of the user', + example: 'tomast1137', + }) + username?: string; + + @IsOptional() + @IsString() + @MaxLength(1024) + @ApiProperty({ + description: 'Description of the user', + example: 'I using noteblock.world', + }) + description?: string; + + @IsOptional() + @Type(() => UserLinks) + @ApiProperty({ + description: 'Social media links of the user', + example: { + github: 'https://github.com/tomast1337', + youtube: 'https://www.youtube.com/@Bentroen_', + spotify: + 'https://open.spotify.com/artist/1McMsnEElThX1knmY4oliG?si=v95i3XbRRgKT9JwyiFiFEg', + bandcamp: 'https://igorrr.bandcamp.com/', + facebook: 'https://www.facebook.com/MrBean', + reddit: 'https://www.reddit.com/user/Unidan/', + soundcloud: 'https://soundcloud.com/futureisnow', + steam: 'https://steamcommunity.com/id/CattleDecapitation/', + x: 'https://x.com/Trail_Cams', + twitch: 'https://www.twitch.tv/vinesauce', + threads: 'https://www.threads.net/@kimkardashian', + tiktok: 'https://www.tiktok.com/@karolg', + snapchat: 'https://www.snapchat.com/add/username', + instagram: 'https://instagram.com/validuser', + discord: 'https://discord.com/validuser', + telegram: 'https://t.me/validuser', + }, + }) + socialLinks?: UserLinks; +} From 2677c074be7be7bced6f3eaf987c6397e7bad78a Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 12:03:15 -0300 Subject: [PATCH 03/56] feat: add updateProfile endpoint and service method in UserController and UserService --- server/src/user/user.controller.ts | 13 +++++++++++++ server/src/user/user.service.ts | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 9087b65c..39a819dc 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -4,6 +4,7 @@ import { GetRequestToken, validateUser } from '@server/GetRequestUser'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; import { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; @@ -47,4 +48,16 @@ export class UserController { user = validateUser(user); return await this.userService.updateUsername(user, body); } + + @Patch('profile') + @ApiTags('user') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update the profile' }) + async updateProfile( + @GetRequestToken() user: UserDocument | null, + @Body() body: UpdateUserProfileDto, + ) { + user = validateUser(user); + return await this.userService.updateProfile(user, body); + } } diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index ec3a10f8..1e590200 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -7,6 +7,7 @@ import { validate } from 'class-validator'; import { Model } from 'mongoose'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { User, UserDocument } from './entity/user.entity'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; @Injectable() export class UserService { @@ -148,4 +149,8 @@ export class UserService { return await user.save(); } + + public async updateProfile(user: UserDocument, body: UpdateUserProfileDto) { + throw new Error('Method not implemented.'); + } } From b54021d50410f2fa666541cb1f47507df3cd54f8 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 18:48:45 -0300 Subject: [PATCH 04/56] feat: add deepFreeze function and corresponding tests for object immutability --- shared/validation/common/deepFreeze.spec.ts | 69 +++++++++++++++++++++ shared/validation/common/deepFreeze.ts | 6 +- 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 shared/validation/common/deepFreeze.spec.ts diff --git a/shared/validation/common/deepFreeze.spec.ts b/shared/validation/common/deepFreeze.spec.ts new file mode 100644 index 00000000..88e296dc --- /dev/null +++ b/shared/validation/common/deepFreeze.spec.ts @@ -0,0 +1,69 @@ +import { deepFreeze } from './deepFreeze'; + +describe('deepFreeze', () => { + it('should deeply freeze an object', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const frozenObj = deepFreeze(obj); + + expect(Object.isFrozen(frozenObj)).toBe(true); + expect(Object.isFrozen(frozenObj.b)).toBe(true); + expect(Object.isFrozen(frozenObj.b.d)).toBe(true); + }); + + it('should not allow modification of a deeply frozen object', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const frozenObj = deepFreeze(obj); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + frozenObj.a = 2; + }).toThrow(TypeError); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + frozenObj.b.c = 3; + }).toThrow(TypeError); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + frozenObj.b.d.e = 4; + }).toThrow(TypeError); + }); + + it('should return the same object reference', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const frozenObj = deepFreeze(obj); + + expect(frozenObj).toBe(obj); + }); +}); diff --git a/shared/validation/common/deepFreeze.ts b/shared/validation/common/deepFreeze.ts index 71b93589..adcb029d 100644 --- a/shared/validation/common/deepFreeze.ts +++ b/shared/validation/common/deepFreeze.ts @@ -1,6 +1,6 @@ -export function deepFreeze( +export const deepFreeze = ( object: T, -): Readonly { +): Readonly => { const propNames = Object.getOwnPropertyNames(object); for (const name of propNames) { @@ -13,4 +13,4 @@ export function deepFreeze( } return Object.freeze(object); -} +}; From 1d8fe636ec545697a8c0d1bf05bdae84b944af4f Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 18:49:34 -0300 Subject: [PATCH 05/56] refactor: remove IsNotEmpty validation from UpdateUsername DTO --- shared/validation/user/dto/UpdateUsername.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/validation/user/dto/UpdateUsername.dto.ts b/shared/validation/user/dto/UpdateUsername.dto.ts index c9afe018..5460c9c3 100644 --- a/shared/validation/user/dto/UpdateUsername.dto.ts +++ b/shared/validation/user/dto/UpdateUsername.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator'; +import { IsString, MaxLength, MinLength } from 'class-validator'; export class UpdateUsernameDto { @IsString() From a766f6998d6fa77c6106900af811645a8930159c Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 18:50:06 -0300 Subject: [PATCH 06/56] feat: integrate deepFreeze for LinkRegexes in UpdateUserProfile DTO --- shared/validation/user/dto/UpdateUserProfile.dto.spec.ts | 2 ++ shared/validation/user/dto/UpdateUserProfile.dto.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts index 873ae318..3eb96bb5 100644 --- a/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts +++ b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts @@ -8,8 +8,10 @@ describe('UpdateUserProfileDto', () => { userLinks.github = 'https://github.com/tomast1337'; userLinks.youtube = 'https://www.youtube.com/@Bentroen_'; + userLinks.spotify = 'https://open.spotify.com/artist/1McMsnEElThX1knmY4oliG?si=v95i3XbRRgKT9JwyiFiFEg'; + userLinks.bandcamp = 'https://igorrr.bandcamp.com/'; userLinks.facebook = 'https://www.facebook.com/MrBean'; userLinks.reddit = 'https://www.reddit.com/user/Unidan/'; diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.ts b/shared/validation/user/dto/UpdateUserProfile.dto.ts index 37ebbfee..4393cb7c 100644 --- a/shared/validation/user/dto/UpdateUserProfile.dto.ts +++ b/shared/validation/user/dto/UpdateUserProfile.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { deepFreeze } from '@shared/validation/common/deepFreeze'; import { Type } from 'class-transformer'; import { IsOptional, @@ -9,7 +10,7 @@ import { MinLength, } from 'class-validator'; -export const LinkRegexes = { +export const LinkRegexes = deepFreeze({ bandcamp: /https?:\/\/[a-zA-Z0-9_-]+\.bandcamp\.com\/?/, discord: /https?:\/\/(www\.)?discord\.com\/[a-zA-Z0-9_]+/, facebook: /https?:\/\/(www\.)?facebook\.com\/[a-zA-Z0-9_]+/, @@ -26,7 +27,7 @@ export const LinkRegexes = { twitch: /https?:\/\/(www\.)?twitch\.tv\/[a-zA-Z0-9_]+/, x: /https?:\/\/(www\.)?x\.com\/[a-zA-Z0-9_]+/, youtube: /https?:\/\/(www\.)?youtube\.com\/@?[a-zA-Z0-9_-]+/, -}; +}); export class UserLinks { @IsOptional() From 288156f168e830159c724bed8e031d2824723b67 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 12 Jan 2025 18:58:26 -0300 Subject: [PATCH 07/56] feat: implement updateProfile method in UserService and corresponding tests --- server/src/user/user.controller.ts | 8 ++-- server/src/user/user.service.spec.ts | 71 ++++++++++++++++++++++++++++ server/src/user/user.service.ts | 10 +++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 39a819dc..e0221191 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,11 +1,13 @@ -import { Controller, Inject, Get, Patch, Body, Query } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { GetRequestToken, validateUser } from '@server/GetRequestUser'; +import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; +import { GetRequestToken, validateUser } from '@server/GetRequestUser'; + import { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 36ac541d..50843ddb 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -8,6 +8,7 @@ import { Model } from 'mongoose'; import { User, UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; const mockUserModel = { create: jest.fn(), @@ -18,6 +19,7 @@ const mockUserModel = { exec: jest.fn(), select: jest.fn(), countDocuments: jest.fn(), + findOneAndUpdate: jest.fn(), }; describe('UserService', () => { @@ -332,6 +334,7 @@ describe('UserService', () => { username: 'testuser', save: jest.fn().mockReturnThis(), } as unknown as UserDocument; + const body = { username: 'newuser' }; jest.spyOn(service, 'usernameExists').mockResolvedValue(false); @@ -358,4 +361,72 @@ describe('UserService', () => { ); }); }); + + describe('updateProfile', () => { + it('should update the user profile successfully', async () => { + const user = { + _id: 'userId', + description: 'old description', + socialLinks: {}, + username: 'oldUsername', + } as unknown as UserDocument; + + const body: UpdateUserProfileDto = { + description: 'new description', + socialLinks: { github: 'https://github.com/newuser' }, + username: 'newUsername', + }; + + const updatedUser = { + ...user, + ...body, + }; + + jest + .spyOn(userModel, 'findOneAndUpdate') + .mockResolvedValue(updatedUser as any); + + const result = await service.updateProfile(user, body); + + expect(result).toEqual(updatedUser); + + expect(userModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: user._id }, + user, + { new: true }, + ); + }); + + it('should update only provided fields', async () => { + const user = { + _id: 'userId', + description: 'old description', + socialLinks: {}, + username: 'oldUsername', + } as unknown as UserDocument; + + const body: UpdateUserProfileDto = { + description: 'new description', + }; + + const updatedUser = { + ...user, + description: 'new description', + }; + + jest + .spyOn(userModel, 'findOneAndUpdate') + .mockResolvedValue(updatedUser as any); + + const result = await service.updateProfile(user, body); + + expect(result).toEqual(updatedUser); + + expect(userModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: user._id }, + user, + { new: true }, + ); + }); + }); }); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 1e590200..784b5e4b 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -151,6 +151,14 @@ export class UserService { } public async updateProfile(user: UserDocument, body: UpdateUserProfileDto) { - throw new Error('Method not implemented.'); + const { description, socialLinks, username } = body; + + if (description) user.description = description; + if (socialLinks) user.socialLinks = socialLinks; + if (username) user.username = username; + + return await this.userModel.findOneAndUpdate({ _id: user._id }, user, { + new: true, + }); } } From 981239e82489c67c27cc1a18c559a64cd2521c7a Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 14 Jan 2025 22:28:29 -0300 Subject: [PATCH 08/56] feat: add localhost configuration for development in next.config.mjs --- web/next.config.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/next.config.mjs b/web/next.config.mjs index e5b1b2a7..e98b847c 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -34,6 +34,12 @@ const nextConfig = { hostname: 'cdn.discordapp.com', port: '', }, + // localhost + { + protocol: 'http', + hostname: 'localhost', + port: '9000', + }, ], }, }; From 6e2826f63343250b632d798bcaf45c90cdcf1ff8 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 14 Jan 2025 22:28:49 -0300 Subject: [PATCH 09/56] feat: add BlockSearch component and integrate it into the Header --- .../components/layout/BlockSearchProps.tsx | 43 +++++++++++++++++++ .../shared/components/layout/BlockTab.tsx | 17 +++++--- .../shared/components/layout/Header.tsx | 6 +++ 3 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 web/src/modules/shared/components/layout/BlockSearchProps.tsx diff --git a/web/src/modules/shared/components/layout/BlockSearchProps.tsx b/web/src/modules/shared/components/layout/BlockSearchProps.tsx new file mode 100644 index 00000000..5680d9d4 --- /dev/null +++ b/web/src/modules/shared/components/layout/BlockSearchProps.tsx @@ -0,0 +1,43 @@ +'use client'; +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { cn } from '@web/src/lib/tailwind.utils'; +import { useState } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +export const BlockSearch = () => { + const [query, setQuery] = useState(''); + + return ( + + + + Search + + +
+ setQuery(e.target.value)} + placeholder='Search for songs and users' + className='w-full bg-zinc-800 text-white border border-zinc-600 rounded-md p-2' + /> + +
+
+
+ ); +}; diff --git a/web/src/modules/shared/components/layout/BlockTab.tsx b/web/src/modules/shared/components/layout/BlockTab.tsx index c3200dad..89169067 100644 --- a/web/src/modules/shared/components/layout/BlockTab.tsx +++ b/web/src/modules/shared/components/layout/BlockTab.tsx @@ -4,17 +4,21 @@ import Link from 'next/link'; import { cn } from '@web/src/lib/tailwind.utils'; +interface BlockTabProps { + href: string; + icon: IconDefinition; + label: string; + className?: string; + id?: string; +} + export const BlockTab = ({ + id, href, icon, label, className, -}: { - href: string; - icon: IconDefinition; - label: string; - className?: string; -}) => { +}: BlockTabProps) => { return ( {label} diff --git a/web/src/modules/shared/components/layout/Header.tsx b/web/src/modules/shared/components/layout/Header.tsx index e59e33fa..37473c35 100644 --- a/web/src/modules/shared/components/layout/Header.tsx +++ b/web/src/modules/shared/components/layout/Header.tsx @@ -16,6 +16,7 @@ import { import { BlockTab } from './BlockTab'; import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; +import { BlockSearch } from './BlockSearchProps'; export async function Header() { let isLogged; @@ -77,26 +78,31 @@ export async function Header() { icon={faMusic} label='Songs' className='bg-purple-700 after:bg-purple-900 before:bg-purple-950' + id='songs-tab' /> + {/* Sign in / Profile */} From 4240d89e8c248333f2573bf843b85af0025a09e4 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Wed, 15 Jan 2025 22:57:36 -0300 Subject: [PATCH 10/56] refactor: change Header component to a constant and add IDs for accessibility --- .../shared/components/layout/Header.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/web/src/modules/shared/components/layout/Header.tsx b/web/src/modules/shared/components/layout/Header.tsx index 37473c35..c3f915b4 100644 --- a/web/src/modules/shared/components/layout/Header.tsx +++ b/web/src/modules/shared/components/layout/Header.tsx @@ -18,7 +18,7 @@ import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; import { BlockSearch } from './BlockSearchProps'; -export async function Header() { +export const Header = async () => { let isLogged; let userData; @@ -34,13 +34,19 @@ export async function Header() { } return ( -
+ ); -} +}; From 2b6490fcfe8725540d6dfa046ebd3a34998dd713 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Thu, 16 Jan 2025 17:47:37 -0300 Subject: [PATCH 11/56] feat: create SearchPage component to display search parameter --- web/src/app/(content)/search/[search]/page.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 web/src/app/(content)/search/[search]/page.tsx diff --git a/web/src/app/(content)/search/[search]/page.tsx b/web/src/app/(content)/search/[search]/page.tsx new file mode 100644 index 00000000..8c03535a --- /dev/null +++ b/web/src/app/(content)/search/[search]/page.tsx @@ -0,0 +1,13 @@ +interface SearchPage { + params: { + search: string; + }; +} + +const Page = ({ params }: SearchPage) => { + const { search } = params; + + return <>search: {search}; +}; + +export default Page; From 0f479df3d6a54063145120159ffbe85c0387bbde Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:56:11 -0300 Subject: [PATCH 12/56] feat: add UserViewDto class for user profile data validation --- shared/validation/user/dto/UserView.dto.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 shared/validation/user/dto/UserView.dto.ts diff --git a/shared/validation/user/dto/UserView.dto.ts b/shared/validation/user/dto/UserView.dto.ts new file mode 100644 index 00000000..1dae3512 --- /dev/null +++ b/shared/validation/user/dto/UserView.dto.ts @@ -0,0 +1,7 @@ +export class UserViewDto { + username: string; + profileImage: string; + constructor(partial: UserViewDto) { + Object.assign(this, partial); + } +} From 776652aac5e104535852d1e00d0bf962744606af Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:56:15 -0300 Subject: [PATCH 13/56] feat: add SearchQueryDTO for search query validation and pagination --- .../validation/common/dto/SearchQuery.dto.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 shared/validation/common/dto/SearchQuery.dto.ts diff --git a/shared/validation/common/dto/SearchQuery.dto.ts b/shared/validation/common/dto/SearchQuery.dto.ts new file mode 100644 index 00000000..8166a6ec --- /dev/null +++ b/shared/validation/common/dto/SearchQuery.dto.ts @@ -0,0 +1,80 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class SearchQueryDTO { + @IsOptional() + @IsString() + @ApiProperty({ + example: 'Nirvana - Dumb', + description: 'Natural language query.', + }) + query?: string; + + @IsOptional() + @IsString() + @ApiProperty({ + example: 'dubstep', + description: 'Filters the results by the specified category.', + }) + category?: string; + + @Min(1) + @ApiProperty({ + example: 1, + description: 'Page number.', + }) + page?: number = 1; + + @IsNumber({ maxDecimalPlaces: 0 }) + @Min(1) + @Max(100) + @ApiProperty({ + example: 20, + description: 'Number of results per page.', + }) + limit?: number = 20; + + @IsString() + @IsOptional() + @ApiProperty({ + example: 'createdAt', + description: 'Sort field.', + }) + sort?: string = 'createdAt'; + + @IsBoolean() + @Transform(({ value }) => value === 'true') + @ApiProperty({ + example: false, + description: 'Sort in ascending order if true, descending if false.', + }) + order?: boolean = false; + + @IsOptional() + @IsBoolean() + @ApiProperty({ + example: true, + description: 'Search Users.', + }) + searchUsers?: boolean = true; + + @IsOptional() + @IsBoolean() + @ApiProperty({ + example: true, + description: 'Search Songs.', + }) + searchSongs?: boolean = true; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} From 0bcd28c6d3871f7e4e78018bb16dbd462c933764 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:56:50 -0300 Subject: [PATCH 14/56] feat: implement search functionality in SongService with pagination and sorting --- server/src/song/song.service.ts | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 4a2e5ea6..fbf135f8 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; import { BROWSER_SONGS } from '@shared/validation/song/constants'; import { SongPageDto } from '@shared/validation/song/dto/SongPageDto'; import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; @@ -463,7 +464,58 @@ export class SongService { return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } + public async search(queryBody: SearchQueryDTO) { + const { + query = '', + page = 1, + limit = 10, + sort = 'createdAt', + order, + category, + } = queryBody; + + const skip = (page - 1) * limit; + const sortOrder = order ? 1 : -1; + + const songs = await this.songModel + .find({ $text: { $search: query }, category: category }) // Ensure a text index is created + .select('title category thumbnailUrl likeCount') + .sort({ [sort]: sortOrder }) + .skip(skip) + .limit(limit); + + const total = await this.songModel.countDocuments(); + + return { + songs: await this.songModel.populate(songs, { + path: 'uploader', + select: 'username profileImage -_id', + }), + total, + page, + limit, + }; + } + public async getAllSongs() { return this.songModel.find({}); } + + public async createSearchIndexes() { + return this.songModel.collection.createIndex( + { + title: 'text', + originalAuthor: 'text', + description: 'text', + }, + { + weights: { + title: 10, + originalAuthor: 5, + description: 1, + }, + name: 'song_search_index', + }, + ); + } } From c22cb53c2e8139b569125fa64e580bd743c62de3 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:56:56 -0300 Subject: [PATCH 15/56] feat: add createdAt and updatedAt fields to User entity --- server/src/user/entity/user.entity.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index d450dc19..ecf147e6 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -85,6 +85,9 @@ export class User { @Prop({ type: Array, required: true, default: [] }) achievements: string[]; + + createdAt: Date; + updatedAt: Date; } export const UserSchema = SchemaFactory.createForClass(User); From 3427d57bddc5bb80ba7a75b609f4a3470d7e0eba Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:57:00 -0300 Subject: [PATCH 16/56] feat: implement user search functionality with pagination and indexing --- server/src/user/user.controller.ts | 1 - server/src/user/user.service.spec.ts | 2 +- server/src/user/user.service.ts | 52 +++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index e0221191..5a05d211 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,6 +1,5 @@ import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; - import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 50843ddb..152db91d 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -4,11 +4,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; import { Model } from 'mongoose'; import { User, UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; -import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; const mockUserModel = { create: jest.fn(), diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 54284443..47c4f8aa 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,14 +1,15 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; import { User, UserDocument } from './entity/user.entity'; -import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; @Injectable() export class UserService { @@ -58,6 +59,37 @@ export class UserService { }; } + public async search(queryBody: SearchQueryDTO) { + const { + query = '', + page = 1, + limit = 10, + sort = 'createdAt', + order, + } = queryBody; + + const skip = (page - 1) * limit; + const sortOrder = order ? 1 : -1; + + const users = await this.userModel + .find({ + $text: { $search: query }, + }) + .select('username publicName email profileImage') + .sort({ [sort]: sortOrder }) + .skip(skip) + .limit(limit); + + const total = await this.userModel.countDocuments(); + + return { + users, + total, + page, + limit, + }; + } + public async getUserByEmailOrId(query: GetUser) { const { email, id, username } = query; @@ -162,4 +194,22 @@ export class UserService { new: true, }); } + + public async createSearchIndexes() { + await this.userModel.collection.createIndex( + { + username: 'text', + publicName: 'text', + description: 'text', + }, + { + weights: { + username: 5, + publicName: 3, + description: 1, + }, + name: 'user_search_index', + }, + ); + } } From c642e260d555e776f289c1220d51570969707101 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:57:13 -0300 Subject: [PATCH 17/56] feat: implement search functionality in SearchService and SearchController with tests --- server/src/search/search.controller.spec.ts | 26 ++++++++ server/src/search/search.controller.ts | 14 ++++ server/src/search/search.module.ts | 14 ++++ server/src/search/search.service.spec.ts | 36 ++++++++++ server/src/search/search.service.ts | 73 +++++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 server/src/search/search.controller.spec.ts create mode 100644 server/src/search/search.controller.ts create mode 100644 server/src/search/search.module.ts create mode 100644 server/src/search/search.service.spec.ts create mode 100644 server/src/search/search.service.ts diff --git a/server/src/search/search.controller.spec.ts b/server/src/search/search.controller.spec.ts new file mode 100644 index 00000000..40183f90 --- /dev/null +++ b/server/src/search/search.controller.spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +describe('SearchController', () => { + let controller: SearchController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SearchController], + providers: [ + { + provide: SearchService, + useValue: {}, + }, + ], + }).compile(); + + controller = module.get(SearchController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/server/src/search/search.controller.ts b/server/src/search/search.controller.ts new file mode 100644 index 00000000..e2ef3d89 --- /dev/null +++ b/server/src/search/search.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; + +import { SearchService } from './search.service'; + +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + async search(@Query() query: PageQueryDTO) { + return await this.searchService.search(query); + } +} diff --git a/server/src/search/search.module.ts b/server/src/search/search.module.ts new file mode 100644 index 00000000..708bbfaa --- /dev/null +++ b/server/src/search/search.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { SongModule } from '@server/song/song.module'; +import { UserModule } from '@server/user/user.module'; + +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +@Module({ + imports: [UserModule, SongModule], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/server/src/search/search.service.spec.ts b/server/src/search/search.service.spec.ts new file mode 100644 index 00000000..15a8caf3 --- /dev/null +++ b/server/src/search/search.service.spec.ts @@ -0,0 +1,36 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { SongService } from '@server/song/song.service'; +import { UserService } from '@server/user/user.service'; + +import { SearchService } from './search.service'; + +describe('SearchService', () => { + let service: SearchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SearchService, + { + provide: UserService, + useValue: { + search: jest.fn(), + }, + }, + { + provide: SongService, + useValue: { + search: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SearchService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/search/search.service.ts b/server/src/search/search.service.ts new file mode 100644 index 00000000..6722eb74 --- /dev/null +++ b/server/src/search/search.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; +import { SongViewDto } from '@shared/validation/song/dto/SongView.dto'; +import { UserViewDto } from '@shared/validation/user/dto/UserView.dto'; + +import { SongService } from '@server/song/song.service'; +import { UserService } from '@server/user/user.service'; + +@Injectable() +export class SearchService { + private readonly logger = new Logger(SearchService.name); + + constructor( + @Inject(UserService) + private readonly userService: UserService, + @Inject(SongService) + private readonly songService: SongService, + ) { + this.logger.log('SearchService instantiated'); + } + + public async search(queryBody: SearchQueryDTO) { + this.logger.debug(`Searching for ${queryBody}`); + + const { + query, + page = 1, + limit = 10, + sort = 'createdAt', + order = false, + category, + searchSongs, + searchUsers, + } = queryBody; + + const { users, total: totalUsers } = searchUsers + ? await this.userService.search({ + ...queryBody, + }) + : { users: [], total: 0 }; + + const { songs, total: totalSongs } = searchSongs + ? await this.songService.search({ + ...queryBody, + }) + : { songs: [], total: 0 }; + + return { + users: users.map( + ({ username, profileImage }) => + new UserViewDto({ + username, + profileImage, + }), + ), + songs: songs.map((song) => SongViewDto.fromSongDocument(song)), + total: Math.max(totalUsers, totalSongs), + query, + page, + limit, + sort, + order, + category, + searchSongs, + searchUsers, + }; + } + + public async createIndexes() { + await this.userService.createSearchIndexes(); + await this.songService.createSearchIndexes(); + } +} From 46d8b32f8572c86af72e8ed8af2c3f3c13011d7e Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 18:57:28 -0300 Subject: [PATCH 18/56] feat: add SearchModule to the application module --- server/src/app.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index ab916a76..7ead9133 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from './auth/auth.module'; import { validate } from './config/EnvironmentVariables'; import { FileModule } from './file/file.module'; import { ParseTokenPipe } from './parseToken'; +import { SearchModule } from './search/search.module'; import { SongModule } from './song/song.module'; import { SongBrowserModule } from './song-browser/song-browser.module'; import { UserModule } from './user/user.module'; @@ -39,6 +40,7 @@ import { UserModule } from './user/user.module'; AuthModule.forRootAsync(), FileModule.forRootAsync(), SongBrowserModule, + SearchModule, ], controllers: [], providers: [ParseTokenPipe], From dedb9ae0ca6635b5a0c4d558ac0996afbeea7856 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 22:23:41 -0300 Subject: [PATCH 19/56] feat: update SearchQueryDTO to make query parameters optional --- shared/validation/common/dto/SearchQuery.dto.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shared/validation/common/dto/SearchQuery.dto.ts b/shared/validation/common/dto/SearchQuery.dto.ts index 8166a6ec..6f5f8694 100644 --- a/shared/validation/common/dto/SearchQuery.dto.ts +++ b/shared/validation/common/dto/SearchQuery.dto.ts @@ -15,6 +15,7 @@ export class SearchQueryDTO { @ApiProperty({ example: 'Nirvana - Dumb', description: 'Natural language query.', + required: false, }) query?: string; @@ -23,6 +24,7 @@ export class SearchQueryDTO { @ApiProperty({ example: 'dubstep', description: 'Filters the results by the specified category.', + required: false, }) category?: string; @@ -30,6 +32,7 @@ export class SearchQueryDTO { @ApiProperty({ example: 1, description: 'Page number.', + required: false, }) page?: number = 1; @@ -39,6 +42,7 @@ export class SearchQueryDTO { @ApiProperty({ example: 20, description: 'Number of results per page.', + required: false, }) limit?: number = 20; @@ -47,6 +51,7 @@ export class SearchQueryDTO { @ApiProperty({ example: 'createdAt', description: 'Sort field.', + required: false, }) sort?: string = 'createdAt'; @@ -55,6 +60,7 @@ export class SearchQueryDTO { @ApiProperty({ example: false, description: 'Sort in ascending order if true, descending if false.', + required: false, }) order?: boolean = false; @@ -63,6 +69,7 @@ export class SearchQueryDTO { @ApiProperty({ example: true, description: 'Search Users.', + required: false, }) searchUsers?: boolean = true; @@ -71,6 +78,7 @@ export class SearchQueryDTO { @ApiProperty({ example: true, description: 'Search Songs.', + required: false, }) searchSongs?: boolean = true; From 30c1d241e11c236067a24d8d22379dd9b49069d2 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 22:23:47 -0300 Subject: [PATCH 20/56] feat: update search controller to use SearchQueryDTO for query parameters --- server/src/search/search.controller.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/search/search.controller.ts b/server/src/search/search.controller.ts index e2ef3d89..037ebbf1 100644 --- a/server/src/search/search.controller.ts +++ b/server/src/search/search.controller.ts @@ -1,14 +1,16 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { ApiTags } from '@nestjs/swagger'; +import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; import { SearchService } from './search.service'; @Controller('search') +@ApiTags('search') export class SearchController { constructor(private readonly searchService: SearchService) {} @Get() - async search(@Query() query: PageQueryDTO) { + async search(@Query() query: SearchQueryDTO) { return await this.searchService.search(query); } } From b070a76f09c3769a4ac9c1fa889f29b23b9b8ef4 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 22:23:55 -0300 Subject: [PATCH 21/56] feat: enhance SearchModule to initialize indexes and improve logging in SearchService --- server/src/search/search.module.ts | 13 +++++++++++-- server/src/search/search.service.ts | 11 +++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/src/search/search.module.ts b/server/src/search/search.module.ts index 708bbfaa..77b6b81d 100644 --- a/server/src/search/search.module.ts +++ b/server/src/search/search.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Inject, Module } from '@nestjs/common'; import { SongModule } from '@server/song/song.module'; import { UserModule } from '@server/user/user.module'; @@ -11,4 +11,13 @@ import { SearchService } from './search.service'; controllers: [SearchController], providers: [SearchService], }) -export class SearchModule {} +export class SearchModule { + constructor( + @Inject(SearchService) + private readonly searchService: SearchService, + ) {} + + onModuleInit() { + this.searchService.createIndexes(); + } +} diff --git a/server/src/search/search.service.ts b/server/src/search/search.service.ts index 6722eb74..fe00e1ab 100644 --- a/server/src/search/search.service.ts +++ b/server/src/search/search.service.ts @@ -20,13 +20,13 @@ export class SearchService { } public async search(queryBody: SearchQueryDTO) { - this.logger.debug(`Searching for ${queryBody}`); + this.logger.debug(`Searching for ${JSON.stringify(queryBody)}`); const { query, page = 1, limit = 10, - sort = 'createdAt', + sort, order = false, category, searchSongs, @@ -67,7 +67,10 @@ export class SearchService { } public async createIndexes() { - await this.userService.createSearchIndexes(); - await this.songService.createSearchIndexes(); + const userIndexResult = await this.userService.createSearchIndexes(); + const songIndexResult = await this.songService.createSearchIndexes(); + + this.logger.debug(`User index: ${userIndexResult}`); + this.logger.debug(`Song index: ${songIndexResult}`); } } From 7aa68c6c138072e99e821f44224283a0f99a387f Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 20 Jan 2025 22:24:04 -0300 Subject: [PATCH 22/56] feat: enhance song and user services to support regex search and improve logging --- server/src/song/song.service.ts | 20 ++++++++++++++++++-- server/src/user/user.service.ts | 21 +++++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index fbf135f8..9fc70f2b 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -478,13 +478,29 @@ export class SongService { const sortOrder = order ? 1 : -1; const songs = await this.songModel - .find({ $text: { $search: query }, category: category }) // Ensure a text index is created + .find({ + $or: [ + { originalAuthor: { $regex: query, $options: 'i' } }, + { title: { $regex: query, $options: 'i' } }, + { description: { $regex: query, $options: 'i' } }, + ], + category: category, + }) .select('title category thumbnailUrl likeCount') .sort({ [sort]: sortOrder }) .skip(skip) .limit(limit); - const total = await this.songModel.countDocuments(); + const total = await this.songModel.countDocuments({ + originalAuthor: { $regex: query, $options: 'i' }, + title: { $regex: query, $options: 'i' }, + description: { $regex: query, $options: 'i' }, + category: category, + }); + + this.logger.debug( + `Retrieved songs: ${songs.length} documents, with total: ${total}`, + ); return { songs: await this.songModel.populate(songs, { diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 47c4f8aa..09215c59 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; @@ -13,6 +13,7 @@ import { User, UserDocument } from './entity/user.entity'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); constructor(@InjectModel(User.name) private userModel: Model) {} public async create(user_registered: CreateUser) { @@ -73,14 +74,26 @@ export class UserService { const users = await this.userModel .find({ - $text: { $search: query }, + $or: [ + { username: { $regex: query, $options: 'i' } }, + { publicName: { $regex: query, $options: 'i' } }, + { description: { $regex: query, $options: 'i' } }, + ], }) .select('username publicName email profileImage') .sort({ [sort]: sortOrder }) .skip(skip) .limit(limit); - const total = await this.userModel.countDocuments(); + const total = await this.userModel.countDocuments({ + username: { $regex: query, $options: 'i' }, + publicName: { $regex: query, $options: 'i' }, + description: { $regex: query, $options: 'i' }, + }); + + this.logger.debug( + `Retrived users: ${users.length} documents, with total: ${total}`, + ); return { users, @@ -196,7 +209,7 @@ export class UserService { } public async createSearchIndexes() { - await this.userModel.collection.createIndex( + return await this.userModel.collection.createIndex( { username: 'text', publicName: 'text', From 2af37e704b7fdd3012230005727fbd25232709d5 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Tue, 21 Jan 2025 14:45:14 -0300 Subject: [PATCH 23/56] feat: optimize search functionality in song and user services with aggregation and indexing --- server/src/search/search.service.ts | 2 + server/src/song/entity/song.entity.ts | 8 +- server/src/song/song.service.ts | 105 +++++++++++++++++++++----- server/src/user/entity/user.entity.ts | 4 +- server/src/user/user.service.ts | 80 +++++++++++++++----- 5 files changed, 157 insertions(+), 42 deletions(-) diff --git a/server/src/search/search.service.ts b/server/src/search/search.service.ts index fe00e1ab..a8abf278 100644 --- a/server/src/search/search.service.ts +++ b/server/src/search/search.service.ts @@ -22,6 +22,8 @@ export class SearchService { public async search(queryBody: SearchQueryDTO) { this.logger.debug(`Searching for ${JSON.stringify(queryBody)}`); + queryBody.query = (queryBody.query || '').trim().toLowerCase(); + const { query, page = 1, diff --git a/server/src/song/entity/song.entity.ts b/server/src/song/entity/song.entity.ts index a678aecb..d5613632 100644 --- a/server/src/song/entity/song.entity.ts +++ b/server/src/song/entity/song.entity.ts @@ -57,7 +57,7 @@ export class Song { @Prop({ type: ThumbnailData, required: true }) thumbnailData: ThumbnailData; - @Prop({ type: String, required: true }) + @Prop({ type: String, required: true, index: true }) category: CategoryType; @Prop({ type: String, required: true }) @@ -72,13 +72,13 @@ export class Song { @Prop({ type: Boolean, required: true, default: true }) allowDownload: boolean; - @Prop({ type: String, required: true }) + @Prop({ type: String, required: true, index: true }) title: string; - @Prop({ type: String, required: false }) + @Prop({ type: String, required: false, index: true }) originalAuthor: string; - @Prop({ type: String, required: false }) + @Prop({ type: String, required: false, index: true }) description: string; // SONG FILE ATTRIBUTES (Populated from NBS file - immutable) diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 9fc70f2b..f72f434b 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -477,26 +477,93 @@ export class SongService { const skip = (page - 1) * limit; const sortOrder = order ? 1 : -1; - const songs = await this.songModel - .find({ - $or: [ - { originalAuthor: { $regex: query, $options: 'i' } }, - { title: { $regex: query, $options: 'i' } }, - { description: { $regex: query, $options: 'i' } }, - ], - category: category, - }) - .select('title category thumbnailUrl likeCount') - .sort({ [sort]: sortOrder }) - .skip(skip) - .limit(limit); + const songs: SongViewDto[] = await this.songModel.aggregate([ + { + /** + $search: { + index: 'song_search_index', + text: { + query: query, + }, + }, + */ + $match: { + $or: [ + { originalAuthor: { $regex: query, $options: 'i' } }, + { title: { $regex: query, $options: 'i' } }, + { description: { $regex: query, $options: 'i' } }, + ], + //category: category, + }, + }, + { + $sort: { [sort]: sortOrder }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + { + $lookup: { + from: 'users', // The collection to join + localField: 'uploader', // The field from the input documents (username) + foreignField: 'username', // The field from the documents of the "from" collection (username) + as: 'uploader', // The name of the new array field to add to the input documents + }, + }, + { + $unwind: '$uploader', // Unwind the array to include the user document directly + }, + { + $project: { + publicId: 1, + createdAt: 1, + thumbnailUrl: 1, + playCount: 1, + downloadCount: 1, + likeCount: 1, + allowDownload: 1, + title: 1, + originalAuthor: 1, + description: 1, + category: 1, + license: 1, + customInstruments: 1, + fileSize: 1, + stats: 1, + 'uploader.username': 1, + 'uploader.profileImage': 1, + }, + }, + ]); - const total = await this.songModel.countDocuments({ - originalAuthor: { $regex: query, $options: 'i' }, - title: { $regex: query, $options: 'i' }, - description: { $regex: query, $options: 'i' }, - category: category, - }); + const totalResult = await this.songModel.aggregate([ + { + /** + $search: { + index: 'song_search_index', + text: { + query: query, + }, + }, + */ + $match: { + $or: [ + { originalAuthor: { $regex: query, $options: 'i' } }, + { title: { $regex: query, $options: 'i' } }, + { description: { $regex: query, $options: 'i' } }, + ], + category: category, + }, + }, + { + $count: 'total', + }, + ]); + + const total = totalResult.length > 0 ? totalResult[0].total : 0; this.logger.debug( `Retrieved songs: ${songs.length} documents, with total: ${total}`, diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index ecf147e6..b384054d 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -30,10 +30,10 @@ export class User { @Prop({ type: Number, required: true, default: 0 }) playCount: number; - @Prop({ type: String, required: true }) + @Prop({ type: String, required: true, index: true }) username: string; - @Prop({ type: String, required: true, default: '#' }) + @Prop({ type: String, required: true, default: '#', index: true }) publicName: string; @Prop({ type: String, required: true, unique: true }) diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 09215c59..ab83cc6f 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -72,24 +72,70 @@ export class UserService { const skip = (page - 1) * limit; const sortOrder = order ? 1 : -1; - const users = await this.userModel - .find({ - $or: [ - { username: { $regex: query, $options: 'i' } }, - { publicName: { $regex: query, $options: 'i' } }, - { description: { $regex: query, $options: 'i' } }, - ], - }) - .select('username publicName email profileImage') - .sort({ [sort]: sortOrder }) - .skip(skip) - .limit(limit); + const users: { + username: string; + profileImage: string; + }[] = await this.userModel.aggregate([ + { + /* + $search: { + index: 'user_search_index', + text: { + query: query, + path: { + wildcard: '*', + }, + }, + }, + */ + $match: { + $or: [ + { username: { $regex: query, $options: 'i' } }, + { publicName: { $regex: query, $options: 'i' } }, + // { description: { $regex: query, $options: 'i' } }, + ], + }, + }, + { + $project: { + username: 1, + profileImage: 1, + }, + }, + { + $sort: { [sort]: sortOrder }, // Sort the results + }, + { + $skip: skip, // Skip the first 'skip' results + }, + { + $limit: limit, // Limit the results to 'limit' + }, + ]); - const total = await this.userModel.countDocuments({ - username: { $regex: query, $options: 'i' }, - publicName: { $regex: query, $options: 'i' }, - description: { $regex: query, $options: 'i' }, - }); + const totalResult = await this.userModel.aggregate([ + { + /* + $search: { + index: 'user_search_index', + text: { + query: query, + }, + },*/ + $match: { + $or: [ + { username: { $regex: query, $options: 'i' } }, + { publicName: { $regex: query, $options: 'i' } }, + // { description: { $regex: query, $options: 'i' } }, + ], + }, + }, + { + $count: 'total', + }, + ]); + + const total = totalResult.length > 0 ? totalResult[0].total : 0; this.logger.debug( `Retrived users: ${users.length} documents, with total: ${total}`, From e4b3a803ce0d6608f6e55480721e083813926423 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Fri, 24 Jan 2025 20:55:29 -0300 Subject: [PATCH 24/56] feat: refactor search services to use text search and improve logging in SearchQueryDTO --- server/src/search/search.service.ts | 17 +++++-- server/src/song/song.service.ts | 36 +++++++-------- server/src/user/user.service.ts | 44 ++++++------------- .../validation/common/dto/SearchQuery.dto.ts | 15 +++---- 4 files changed, 47 insertions(+), 65 deletions(-) diff --git a/server/src/search/search.service.ts b/server/src/search/search.service.ts index a8abf278..e124318d 100644 --- a/server/src/search/search.service.ts +++ b/server/src/search/search.service.ts @@ -20,8 +20,6 @@ export class SearchService { } public async search(queryBody: SearchQueryDTO) { - this.logger.debug(`Searching for ${JSON.stringify(queryBody)}`); - queryBody.query = (queryBody.query || '').trim().toLowerCase(); const { @@ -29,12 +27,25 @@ export class SearchService { page = 1, limit = 10, sort, - order = false, + order, category, searchSongs, searchUsers, } = queryBody; + this.logger.debug( + `Searching for ${JSON.stringify({ + query, + page, + limit, + sort, + order, + category, + searchSongs, + searchUsers, + })}`, + ); + const { users, total: totalUsers } = searchUsers ? await this.userService.search({ ...queryBody, diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index f72f434b..8067a3b4 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -479,25 +479,19 @@ export class SongService { const songs: SongViewDto[] = await this.songModel.aggregate([ { - /** - $search: { - index: 'song_search_index', - text: { - query: query, - }, - }, - */ $match: { - $or: [ - { originalAuthor: { $regex: query, $options: 'i' } }, - { title: { $regex: query, $options: 'i' } }, - { description: { $regex: query, $options: 'i' } }, - ], - //category: category, + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + ...(category && { category: category }), }, }, { - $sort: { [sort]: sortOrder }, + $sort: { + [sort]: sortOrder, + }, }, { $skip: skip, @@ -550,12 +544,12 @@ export class SongService { }, */ $match: { - $or: [ - { originalAuthor: { $regex: query, $options: 'i' } }, - { title: { $regex: query, $options: 'i' } }, - { description: { $regex: query, $options: 'i' } }, - ], - category: category, + $text: { + $search: query, + $caseSensitive: false, // Case-insensitive search + $diacriticSensitive: false, // Diacritic-insensitive search + }, + ...(category && { category: category }), }, }, { diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index ab83cc6f..56181c31 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -77,23 +77,12 @@ export class UserService { profileImage: string; }[] = await this.userModel.aggregate([ { - /* - $search: { - index: 'user_search_index', - text: { - query: query, - path: { - wildcard: '*', - }, - }, - }, - */ $match: { - $or: [ - { username: { $regex: query, $options: 'i' } }, - { publicName: { $regex: query, $options: 'i' } }, - // { description: { $regex: query, $options: 'i' } }, - ], + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, }, }, { @@ -103,31 +92,24 @@ export class UserService { }, }, { - $sort: { [sort]: sortOrder }, // Sort the results + $sort: { [sort]: sortOrder }, }, { - $skip: skip, // Skip the first 'skip' results + $skip: skip, }, { - $limit: limit, // Limit the results to 'limit' + $limit: limit, }, ]); const totalResult = await this.userModel.aggregate([ { - /* - $search: { - index: 'user_search_index', - text: { - query: query, - }, - },*/ $match: { - $or: [ - { username: { $regex: query, $options: 'i' } }, - { publicName: { $regex: query, $options: 'i' } }, - // { description: { $regex: query, $options: 'i' } }, - ], + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, }, }, { diff --git a/shared/validation/common/dto/SearchQuery.dto.ts b/shared/validation/common/dto/SearchQuery.dto.ts index 6f5f8694..e78167b0 100644 --- a/shared/validation/common/dto/SearchQuery.dto.ts +++ b/shared/validation/common/dto/SearchQuery.dto.ts @@ -15,7 +15,6 @@ export class SearchQueryDTO { @ApiProperty({ example: 'Nirvana - Dumb', description: 'Natural language query.', - required: false, }) query?: string; @@ -32,9 +31,8 @@ export class SearchQueryDTO { @ApiProperty({ example: 1, description: 'Page number.', - required: false, }) - page?: number = 1; + page?: number; @IsNumber({ maxDecimalPlaces: 0 }) @Min(1) @@ -42,7 +40,6 @@ export class SearchQueryDTO { @ApiProperty({ example: 20, description: 'Number of results per page.', - required: false, }) limit?: number = 20; @@ -53,7 +50,7 @@ export class SearchQueryDTO { description: 'Sort field.', required: false, }) - sort?: string = 'createdAt'; + sort?: string; @IsBoolean() @Transform(({ value }) => value === 'true') @@ -62,25 +59,23 @@ export class SearchQueryDTO { description: 'Sort in ascending order if true, descending if false.', required: false, }) - order?: boolean = false; + order?: boolean; @IsOptional() @IsBoolean() @ApiProperty({ example: true, description: 'Search Users.', - required: false, }) - searchUsers?: boolean = true; + searchUsers?: boolean; @IsOptional() @IsBoolean() @ApiProperty({ example: true, description: 'Search Songs.', - required: false, }) - searchSongs?: boolean = true; + searchSongs?: boolean; constructor(partial: Partial) { Object.assign(this, partial); From f52bdcb555ffa1ed02ae8918b04500c9cee52bfd Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 17:47:18 -0300 Subject: [PATCH 25/56] style: import order and separation --- server/src/search/search.controller.ts | 2 +- shared/validation/user/dto/UpdateUserProfile.dto.spec.ts | 1 + shared/validation/user/dto/UpdateUserProfile.dto.ts | 3 ++- web/src/modules/shared/components/layout/BlockSearchProps.tsx | 4 +++- web/src/modules/shared/components/layout/Header.tsx | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/src/search/search.controller.ts b/server/src/search/search.controller.ts index 037ebbf1..686b5ec4 100644 --- a/server/src/search/search.controller.ts +++ b/server/src/search/search.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Query } from '@nestjs/common'; - import { ApiTags } from '@nestjs/swagger'; import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; + import { SearchService } from './search.service'; @Controller('search') diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts index 3eb96bb5..5fb2e726 100644 --- a/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts +++ b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts @@ -1,4 +1,5 @@ import { validate } from 'class-validator'; + import { UserLinks } from './UpdateUserProfile.dto'; describe('UpdateUserProfileDto', () => { diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.ts b/shared/validation/user/dto/UpdateUserProfile.dto.ts index 4393cb7c..4ecf5909 100644 --- a/shared/validation/user/dto/UpdateUserProfile.dto.ts +++ b/shared/validation/user/dto/UpdateUserProfile.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { deepFreeze } from '@shared/validation/common/deepFreeze'; import { Type } from 'class-transformer'; import { IsOptional, @@ -10,6 +9,8 @@ import { MinLength, } from 'class-validator'; +import { deepFreeze } from '@shared/validation/common/deepFreeze'; + export const LinkRegexes = deepFreeze({ bandcamp: /https?:\/\/[a-zA-Z0-9_-]+\.bandcamp\.com\/?/, discord: /https?:\/\/(www\.)?discord\.com\/[a-zA-Z0-9_]+/, diff --git a/web/src/modules/shared/components/layout/BlockSearchProps.tsx b/web/src/modules/shared/components/layout/BlockSearchProps.tsx index 5680d9d4..d88bf7c5 100644 --- a/web/src/modules/shared/components/layout/BlockSearchProps.tsx +++ b/web/src/modules/shared/components/layout/BlockSearchProps.tsx @@ -1,8 +1,10 @@ 'use client'; import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { cn } from '@web/src/lib/tailwind.utils'; import { useState } from 'react'; + +import { cn } from '@web/src/lib/tailwind.utils'; + import { Popover, PopoverContent, PopoverTrigger } from './popover'; export const BlockSearch = () => { diff --git a/web/src/modules/shared/components/layout/Header.tsx b/web/src/modules/shared/components/layout/Header.tsx index c3f915b4..3a383897 100644 --- a/web/src/modules/shared/components/layout/Header.tsx +++ b/web/src/modules/shared/components/layout/Header.tsx @@ -13,10 +13,10 @@ import { getUserData, } from '@web/src/modules/auth/features/auth.utils'; +import { BlockSearch } from './BlockSearchProps'; import { BlockTab } from './BlockTab'; import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; -import { BlockSearch } from './BlockSearchProps'; export const Header = async () => { let isLogged; From f4cadb0853a8728ed7a369108330e247ce0946b4 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 17:47:41 -0300 Subject: [PATCH 26/56] chore: set window title to workspace name in VS Code workspace settings --- NoteBlockWorld.code-workspace | 1 + 1 file changed, 1 insertion(+) diff --git a/NoteBlockWorld.code-workspace b/NoteBlockWorld.code-workspace index cb438c31..9b8086f6 100644 --- a/NoteBlockWorld.code-workspace +++ b/NoteBlockWorld.code-workspace @@ -18,6 +18,7 @@ } ], "settings": { + "window.title": "${dirty}${rootName}", "editor.formatOnSave": true, "eslint.validate": ["typescript"], "eslint.run": "onType", From 94119ceeffb8fbb0bc3f8d52b00f64c2a8a9cdee Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 18:44:59 -0300 Subject: [PATCH 27/56] feat: re-enable user page and add route for getting user data by username --- server/src/user/user.controller.ts | 17 +++++++++-- server/src/user/user.service.ts | 21 ++++++++----- web/src/app/(content)/user/[id]/page.tsx | 23 ++++++++++++++ .../app/(content)/user/[id]/page_disable.tsx | 30 ------------------- web/src/modules/user/features/user.util.ts | 2 +- 5 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 web/src/app/(content)/user/[id]/page.tsx delete mode 100644 web/src/app/(content)/user/[id]/page_disable.tsx diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 5a05d211..2d75e212 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; @@ -19,8 +27,13 @@ export class UserController { private readonly userService: UserService, ) {} + @Get('/:username') + async getUser(@Param('username') username: string) { + return await this.userService.getUserByEmailOrId({ username: username }); + } + @Get('by-query') - async getUser(@Query() query: GetUser) { + async getUserByQuery(@Query() query: GetUser) { return await this.userService.getUserByEmailOrId(query); } diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 1357372e..8d0eafd2 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -6,6 +6,7 @@ import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; +import { UserViewDto } from '@shared/validation/user/dto/UserView.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; @@ -39,6 +40,12 @@ export class UserService { return user; } + public async findByUsername(username: string): Promise { + const user = await this.userModel.findOne({ username }).exec(); + + return user; + } + public async getUserPaginated(query: PageQueryDTO) { const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; @@ -134,24 +141,24 @@ export class UserService { public async getUserByEmailOrId(query: GetUser) { const { email, id, username } = query; + let user; if (email) { - return await this.findByEmail(email); + user = await this.findByEmail(email); } if (id) { - return await this.findByID(id); + user = await this.findByID(id); } if (username) { - throw new HttpException( - 'Username is not supported yet', - HttpStatus.BAD_REQUEST, - ); + user = await this.findByUsername(username); } + if (user) return new UserViewDto(user); + throw new HttpException( - 'You must provide an email or an id', + 'You must provide an email, ID or username', HttpStatus.BAD_REQUEST, ); } diff --git a/web/src/app/(content)/user/[id]/page.tsx b/web/src/app/(content)/user/[id]/page.tsx new file mode 100644 index 00000000..ce64d307 --- /dev/null +++ b/web/src/app/(content)/user/[id]/page.tsx @@ -0,0 +1,23 @@ +import { ErrorBox } from '@web/src/modules/shared/components/client/ErrorBox'; +import UserProfile from '@web/src/modules/user/components/UserProfile'; +import { getUserProfileData } from '@web/src/modules/user/features/user.util'; + +const UserPage = async ({ params }: { params: { id: string } }) => { + const { id } = params; + + let userData; + + try { + userData = await getUserProfileData(id); + } catch { + userData = null; + } + + return !userData ? ( + + ) : ( + + ); +}; + +export default UserPage; diff --git a/web/src/app/(content)/user/[id]/page_disable.tsx b/web/src/app/(content)/user/[id]/page_disable.tsx deleted file mode 100644 index d3680b43..00000000 --- a/web/src/app/(content)/user/[id]/page_disable.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import UserProfile from '@web/src/modules/user/components/UserProfile'; -import { getUserProfileData } from '@web/src/modules/user/features/user.util'; - -import Layout from '../../layout'; - -const UserPage = async ({ params }: { params: { id: string } }) => { - const { id } = params; - - try { - const userData = await getUserProfileData(id); - - return ( -
- - - -
- ); - } catch { - return ( -
- -

Failed to get user data

-
-
- ); - } -}; - -export default UserPage; diff --git a/web/src/modules/user/features/user.util.ts b/web/src/modules/user/features/user.util.ts index 70e8dd00..1c4efabc 100644 --- a/web/src/modules/user/features/user.util.ts +++ b/web/src/modules/user/features/user.util.ts @@ -5,7 +5,7 @@ export const getUserProfileData = async ( id: string, ): Promise => { try { - const res = await axiosInstance.get(`/user/?id=${id}`); + const res = await axiosInstance.get(`/user/${id}`); if (res.status === 200) return res.data as UserProfileData; else throw new Error('Failed to get user data'); } catch { From 23b7f2d5ca99b69ea1e50667fdb9c9795d55195f Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 18:48:19 -0300 Subject: [PATCH 28/56] style: enforce blank line between class methods --- .eslintrc.js | 9 +++++++-- server/src/auth/auth.controller.ts | 1 + server/src/auth/auth.service.ts | 1 + server/src/auth/strategies/JWT.strategy.ts | 1 + server/src/auth/strategies/discord.strategy/Strategy.ts | 1 + server/src/auth/strategies/discord.strategy/index.ts | 1 + server/src/auth/strategies/github.strategy.ts | 1 + server/src/auth/strategies/google.strategy.ts | 1 + server/src/song/song.service.ts | 1 + server/src/user/dto/user.dto.ts | 1 + server/src/user/user.service.ts | 1 + shared/validation/user/dto/UserView.dto.ts | 1 + 12 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 675f56e9..1b765e7a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -36,8 +36,13 @@ module.exports = { ], '@typescript-eslint/lines-between-class-members': [ 'warn', - 'always', - { exceptAfterSingleLine: true }, + { + enforce: [ + { blankLine: 'any', prev: '*', next: 'field' }, + { blankLine: 'any', prev: 'field', next: '*' }, + { blankLine: 'always', prev: '*', next: 'method' }, + ], + }, ], 'padding-line-between-statements': 'off', '@typescript-eslint/padding-line-between-statements': [ diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index c51828e7..8737fa64 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -9,6 +9,7 @@ import { AuthService } from './auth.service'; @ApiTags('auth') export class AuthController { private readonly logger = new Logger(AuthController.name); + constructor(private readonly authService: AuthService) {} @Get('login/github') diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 5d83b8d2..2a0e2270 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -16,6 +16,7 @@ import { TokenPayload, Tokens } from './types/token'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); + constructor( @Inject(UserService) private readonly userService: UserService, diff --git a/server/src/auth/strategies/JWT.strategy.ts b/server/src/auth/strategies/JWT.strategy.ts index 6311d4e4..a06c76e7 100644 --- a/server/src/auth/strategies/JWT.strategy.ts +++ b/server/src/auth/strategies/JWT.strategy.ts @@ -7,6 +7,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { private static logger = new Logger(JwtStrategy.name); + constructor(@Inject(ConfigService) config: ConfigService) { const JWT_SECRET = config.getOrThrow('JWT_SECRET'); diff --git a/server/src/auth/strategies/discord.strategy/Strategy.ts b/server/src/auth/strategies/discord.strategy/Strategy.ts index d0c16949..5ca74604 100644 --- a/server/src/auth/strategies/discord.strategy/Strategy.ts +++ b/server/src/auth/strategies/discord.strategy/Strategy.ts @@ -35,6 +35,7 @@ export default class Strategy extends OAuth2Strategy { private fetchScopeEnabled: boolean; public override name = 'discord'; prompt?: string; + public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) { super( { diff --git a/server/src/auth/strategies/discord.strategy/index.ts b/server/src/auth/strategies/discord.strategy/index.ts index 61dc578a..9cb2a49f 100644 --- a/server/src/auth/strategies/discord.strategy/index.ts +++ b/server/src/auth/strategies/discord.strategy/index.ts @@ -8,6 +8,7 @@ import { DiscordPermissionScope } from './types'; @Injectable() export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { private static logger = new Logger(DiscordStrategy.name); + constructor( @Inject(ConfigService) configService: ConfigService, diff --git a/server/src/auth/strategies/github.strategy.ts b/server/src/auth/strategies/github.strategy.ts index 27293151..f3ee1d33 100644 --- a/server/src/auth/strategies/github.strategy.ts +++ b/server/src/auth/strategies/github.strategy.ts @@ -6,6 +6,7 @@ import strategy from 'passport-github'; @Injectable() export class GithubStrategy extends PassportStrategy(strategy, 'github') { private static logger = new Logger(GithubStrategy.name); + constructor( @Inject(ConfigService) configService: ConfigService, diff --git a/server/src/auth/strategies/google.strategy.ts b/server/src/auth/strategies/google.strategy.ts index a19e1789..e219916c 100644 --- a/server/src/auth/strategies/google.strategy.ts +++ b/server/src/auth/strategies/google.strategy.ts @@ -6,6 +6,7 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { private static logger = new Logger(GoogleStrategy.name); + constructor( @Inject(ConfigService) configService: ConfigService, diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 8067a3b4..f62f30b5 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -27,6 +27,7 @@ import { removeExtraSpaces } from './song.util'; @Injectable() export class SongService { private logger = new Logger(SongService.name); + constructor( @InjectModel(SongEntity.name) private songModel: Model, diff --git a/server/src/user/dto/user.dto.ts b/server/src/user/dto/user.dto.ts index a611c20f..2d74ccad 100644 --- a/server/src/user/dto/user.dto.ts +++ b/server/src/user/dto/user.dto.ts @@ -4,6 +4,7 @@ export class UserDto { username: string; publicName: string; email: string; + static fromEntity(user: User): UserDto { const userDto: UserDto = { username: user.username, diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 8d0eafd2..24fa89ef 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -16,6 +16,7 @@ import { User, UserDocument } from './entity/user.entity'; @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); + constructor(@InjectModel(User.name) private userModel: Model) {} public async create(user_registered: CreateUser) { diff --git a/shared/validation/user/dto/UserView.dto.ts b/shared/validation/user/dto/UserView.dto.ts index 1dae3512..7b00c66f 100644 --- a/shared/validation/user/dto/UserView.dto.ts +++ b/shared/validation/user/dto/UserView.dto.ts @@ -1,6 +1,7 @@ export class UserViewDto { username: string; profileImage: string; + constructor(partial: UserViewDto) { Object.assign(this, partial); } From c85f82333325b541772e0f3de6c35a50fcbddcac Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 19:05:54 -0300 Subject: [PATCH 29/56] refactor: drop fields that will no longer be stored in the user entity --- server/src/user/entity/user.entity.ts | 18 ------------------ web/src/modules/auth/types/User.ts | 9 --------- .../modules/user/components/UserProfile.tsx | 12 ------------ 3 files changed, 39 deletions(-) diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index c2ff59f5..33fac1c8 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -71,24 +71,6 @@ export class User { @Prop({ type: Boolean, required: true, default: true }) prefersDarkTheme: boolean; - @Prop({ type: Array, required: true, default: [] }) - likedSongs: string[]; - - @Prop({ type: Array, required: true, default: [] }) - following: string[]; - - @Prop({ type: Array, required: true, default: [] }) - likedComments: string[]; - - @Prop({ type: Array, required: true, default: [] }) - dislikedComments: string[]; - - @Prop({ type: Array, required: true, default: [] }) - notifications: string[]; - - @Prop({ type: Array, required: true, default: [] }) - achievements: string[]; - createdAt: Date; updatedAt: Date; } diff --git a/web/src/modules/auth/types/User.ts b/web/src/modules/auth/types/User.ts index 5a5c901b..9ed24892 100644 --- a/web/src/modules/auth/types/User.ts +++ b/web/src/modules/auth/types/User.ts @@ -18,12 +18,6 @@ export type LoggedUserData = { creationDate: string; lastEdited: string; lastLogin: string; - likedSongs: string[]; // Assuming the array contains strings - following: string[]; // Assuming the array contains strings - likedComments: string[]; // Assuming the array contains strings - dislikedComments: string[]; // Assuming the array contains strings - notifications: string[]; // Assuming the array contains strings - achievements: string[]; // Assuming the array contains strings createdAt: string; updatedAt: string; id: string; @@ -60,7 +54,4 @@ export type UserProfileData = { description: string; profileImage: string; socialLinks: SocialLinks; - likedSongs: string[]; - following: string[]; - achievements: string[]; }; diff --git a/web/src/modules/user/components/UserProfile.tsx b/web/src/modules/user/components/UserProfile.tsx index d7b58103..4ac515af 100644 --- a/web/src/modules/user/components/UserProfile.tsx +++ b/web/src/modules/user/components/UserProfile.tsx @@ -15,9 +15,6 @@ const UserProfile = ({ userData }: UserProfileProps) => { description, profileImage, socialLinks, - likedSongs, - following, - achievements, } = userData; return ( @@ -34,7 +31,6 @@ const UserProfile = ({ userData }: UserProfileProps) => {

Last Login: {lastLogin.toLocaleString()}

Login Streak: {loginStreak}

Play Count: {playCount}

-

Following: {following}

    {Object.keys(socialLinks).map((key, index) => { const link = socialLinks[key as SocialLinksTypes]; @@ -49,14 +45,6 @@ const UserProfile = ({ userData }: UserProfileProps) => { ); })}
-
    -
      - {achievements.map((achievement, index) => ( -
    • - {achievement} -
    • - ))} -
    ); }; From 0380f04f94dde8fa40549a888c6ba59b86ee2ffa Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 19:12:07 -0300 Subject: [PATCH 30/56] style: add comment about auto-added mongoose timestamps --- server/src/song/entity/song.entity.ts | 4 ++-- server/src/user/entity/user.entity.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/song/entity/song.entity.ts b/server/src/song/entity/song.entity.ts index d5613632..13722e95 100644 --- a/server/src/song/entity/song.entity.ts +++ b/server/src/song/entity/song.entity.ts @@ -24,10 +24,10 @@ export class Song { publicId: string; @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - createdAt: Date; + createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - updatedAt: Date; + updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html @Prop({ type: MongooseSchema.Types.ObjectId, required: true, ref: 'User' }) uploader: Types.ObjectId; diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index 33fac1c8..8fe28269 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -71,8 +71,8 @@ export class User { @Prop({ type: Boolean, required: true, default: true }) prefersDarkTheme: boolean; - createdAt: Date; - updatedAt: Date; + createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html + updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html } export const UserSchema = SchemaFactory.createForClass(User); From 1ed32cb1ebbc8e1ace35f537df0c9e13bd5bd07e Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sun, 2 Mar 2025 19:05:54 -0300 Subject: [PATCH 31/56] refactor: drop fields that will no longer be stored in the user entity --- server/src/user/entity/user.entity.ts | 18 ------------------ web/src/modules/auth/types/User.ts | 9 --------- .../modules/user/components/UserProfile.tsx | 12 ------------ 3 files changed, 39 deletions(-) diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index 8a16b236..d4f66e1e 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -70,24 +70,6 @@ export class User { @Prop({ type: Boolean, required: true, default: true }) prefersDarkTheme: boolean; - - @Prop({ type: Array, required: true, default: [] }) - likedSongs: string[]; - - @Prop({ type: Array, required: true, default: [] }) - following: string[]; - - @Prop({ type: Array, required: true, default: [] }) - likedComments: string[]; - - @Prop({ type: Array, required: true, default: [] }) - dislikedComments: string[]; - - @Prop({ type: Array, required: true, default: [] }) - notifications: string[]; - - @Prop({ type: Array, required: true, default: [] }) - achievements: string[]; } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/web/src/modules/auth/types/User.ts b/web/src/modules/auth/types/User.ts index 5a5c901b..9ed24892 100644 --- a/web/src/modules/auth/types/User.ts +++ b/web/src/modules/auth/types/User.ts @@ -18,12 +18,6 @@ export type LoggedUserData = { creationDate: string; lastEdited: string; lastLogin: string; - likedSongs: string[]; // Assuming the array contains strings - following: string[]; // Assuming the array contains strings - likedComments: string[]; // Assuming the array contains strings - dislikedComments: string[]; // Assuming the array contains strings - notifications: string[]; // Assuming the array contains strings - achievements: string[]; // Assuming the array contains strings createdAt: string; updatedAt: string; id: string; @@ -60,7 +54,4 @@ export type UserProfileData = { description: string; profileImage: string; socialLinks: SocialLinks; - likedSongs: string[]; - following: string[]; - achievements: string[]; }; diff --git a/web/src/modules/user/components/UserProfile.tsx b/web/src/modules/user/components/UserProfile.tsx index d7b58103..4ac515af 100644 --- a/web/src/modules/user/components/UserProfile.tsx +++ b/web/src/modules/user/components/UserProfile.tsx @@ -15,9 +15,6 @@ const UserProfile = ({ userData }: UserProfileProps) => { description, profileImage, socialLinks, - likedSongs, - following, - achievements, } = userData; return ( @@ -34,7 +31,6 @@ const UserProfile = ({ userData }: UserProfileProps) => {

    Last Login: {lastLogin.toLocaleString()}

    Login Streak: {loginStreak}

    Play Count: {playCount}

    -

    Following: {following}

      {Object.keys(socialLinks).map((key, index) => { const link = socialLinks[key as SocialLinksTypes]; @@ -49,14 +45,6 @@ const UserProfile = ({ userData }: UserProfileProps) => { ); })}
    -
      -
        - {achievements.map((achievement, index) => ( -
      • - {achievement} -
      • - ))} -
      ); }; From eba38e4ef40ba715a40a116eb8779e6928b4e3ee Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Mar 2025 14:31:34 -0300 Subject: [PATCH 32/56] refactor: rename `UserViewDto` to `UserPreviewDto` and create new DTO for user profile --- server/src/search/search.service.ts | 4 +-- server/src/user/user.service.ts | 14 ++++---- .../{UserView.dto.ts => UserPreview.dto.ts} | 4 +-- .../user/dto/UserProfileView.dto.ts | 33 +++++++++++++++++ web/src/modules/auth/types/User.ts | 35 ++----------------- .../modules/user/components/UserProfile.tsx | 17 +++++---- 6 files changed, 55 insertions(+), 52 deletions(-) rename shared/validation/user/dto/{UserView.dto.ts => UserPreview.dto.ts} (54%) create mode 100644 shared/validation/user/dto/UserProfileView.dto.ts diff --git a/server/src/search/search.service.ts b/server/src/search/search.service.ts index e124318d..25d33784 100644 --- a/server/src/search/search.service.ts +++ b/server/src/search/search.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; import { SongViewDto } from '@shared/validation/song/dto/SongView.dto'; -import { UserViewDto } from '@shared/validation/user/dto/UserView.dto'; +import { UserPreviewDto } from '@shared/validation/user/dto/UserPreview.dto'; import { SongService } from '@server/song/song.service'; import { UserService } from '@server/user/user.service'; @@ -61,7 +61,7 @@ export class SearchService { return { users: users.map( ({ username, profileImage }) => - new UserViewDto({ + new UserPreviewDto({ username, profileImage, }), diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 24fa89ef..7fa85e50 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -6,7 +6,7 @@ import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; -import { UserViewDto } from '@shared/validation/user/dto/UserView.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; @@ -156,12 +156,14 @@ export class UserService { user = await this.findByUsername(username); } - if (user) return new UserViewDto(user); + if (!user) { + throw new HttpException( + 'You must provide an email, ID or username', + HttpStatus.BAD_REQUEST, + ); + } - throw new HttpException( - 'You must provide an email, ID or username', - HttpStatus.BAD_REQUEST, - ); + return UserProfileViewDto.fromUserDocument(user); } public async getHydratedUser(user: UserDocument) { diff --git a/shared/validation/user/dto/UserView.dto.ts b/shared/validation/user/dto/UserPreview.dto.ts similarity index 54% rename from shared/validation/user/dto/UserView.dto.ts rename to shared/validation/user/dto/UserPreview.dto.ts index 7b00c66f..da3e1c87 100644 --- a/shared/validation/user/dto/UserView.dto.ts +++ b/shared/validation/user/dto/UserPreview.dto.ts @@ -1,8 +1,8 @@ -export class UserViewDto { +export class UserPreviewDto { username: string; profileImage: string; - constructor(partial: UserViewDto) { + constructor(partial: UserPreviewDto) { Object.assign(this, partial); } } diff --git a/shared/validation/user/dto/UserProfileView.dto.ts b/shared/validation/user/dto/UserProfileView.dto.ts new file mode 100644 index 00000000..002e6350 --- /dev/null +++ b/shared/validation/user/dto/UserProfileView.dto.ts @@ -0,0 +1,33 @@ +import { UserDocument } from '@server/user/entity/user.entity'; + +export class UserProfileViewDto { + username: string; + publicName: string; + profileImage: string; + description: string; + lastSeen: Date; + loginCount: number; + loginStreak: number; + playCount: number; + // socialLinks: Record; + + public static fromUserDocument(user: UserDocument): UserProfileViewDto { + return new UserProfileViewDto({ + username: user.username, + publicName: user.publicName, + profileImage: user.profileImage, + description: user.description, + lastSeen: user.lastSeen, + loginCount: user.loginCount, + loginStreak: user.loginStreak, + playCount: user.playCount, + // socialLinks: user.socialLinks, + }); + } + + constructor(partial: UserProfileViewDto) { + Object.assign(this, partial); + } +} + +// TODO: refactor all DTOs as ...Request.dto and ...Response.dto diff --git a/web/src/modules/auth/types/User.ts b/web/src/modules/auth/types/User.ts index 9ed24892..2ff5ce78 100644 --- a/web/src/modules/auth/types/User.ts +++ b/web/src/modules/auth/types/User.ts @@ -17,41 +17,10 @@ export type LoggedUserData = { prefersDarkTheme: boolean; creationDate: string; lastEdited: string; - lastLogin: string; + lastSeen: string; createdAt: string; updatedAt: string; id: string; }; -export enum SocialLinksTypes { - BANDCAMP = 'bandcamp', - DISCORD = 'discord', - FACEBOOK = 'facebook', - GITHUB = 'github', - INSTAGRAM = 'instagram', - REDDIT = 'reddit', - SNAPCHAT = 'snapchat', - SOUNDCLOUD = 'soundcloud', - SPOTIFY = 'spotify', - STEAM = 'steam', - TELEGRAM = 'telegram', - TIKTOK = 'tiktok', - THREADS = 'threads', - TWITCH = 'twitch', - X = 'x', - YOUTUBE = 'youtube', -} - -export type SocialLinks = { - [K in SocialLinksTypes]?: string; -}; - -export type UserProfileData = { - lastLogin: Date; - loginStreak: number; - playCount: number; - publicName: string; - description: string; - profileImage: string; - socialLinks: SocialLinks; -}; +// TODO: make this a DTO (part of the validation module) diff --git a/web/src/modules/user/components/UserProfile.tsx b/web/src/modules/user/components/UserProfile.tsx index 4ac515af..358daaee 100644 --- a/web/src/modules/user/components/UserProfile.tsx +++ b/web/src/modules/user/components/UserProfile.tsx @@ -1,20 +1,19 @@ +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import Image from 'next/image'; -import { SocialLinksTypes, UserProfileData } from '../../auth/types/User'; - type UserProfileProps = { - userData: UserProfileData; + userData: UserProfileViewDto; }; const UserProfile = ({ userData }: UserProfileProps) => { const { - lastLogin, + lastSeen, loginStreak, playCount, publicName, description, profileImage, - socialLinks, + // socialLinks, } = userData; return ( @@ -28,12 +27,12 @@ const UserProfile = ({ userData }: UserProfileProps) => { />

      {publicName}

      {description}

      -

      Last Login: {lastLogin.toLocaleString()}

      +

      Last Login: {lastSeen.toLocaleString()}

      Login Streak: {loginStreak}

      Play Count: {playCount}

      -
        + {/*
          {Object.keys(socialLinks).map((key, index) => { - const link = socialLinks[key as SocialLinksTypes]; + const link = socialLinks[key as keyof UserLinks]; if (!link) return null; return ( @@ -44,7 +43,7 @@ const UserProfile = ({ userData }: UserProfileProps) => { ); })} -
        +
      */} ); }; From c44287aec1e1e3b41989442e3ca0806d8f60314e Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Mar 2025 14:40:59 -0300 Subject: [PATCH 33/56] feat: add link to user profile in song page user badge --- .../song/components/SongPageButtons.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/web/src/modules/song/components/SongPageButtons.tsx b/web/src/modules/song/components/SongPageButtons.tsx index fa238c56..013634f1 100644 --- a/web/src/modules/song/components/SongPageButtons.tsx +++ b/web/src/modules/song/components/SongPageButtons.tsx @@ -30,17 +30,22 @@ import { downloadSongFile, openSongInNBS } from '../util/downloadSong'; const UploaderBadge = ({ user }: { user: SongViewDtoType['uploader'] }) => { return (
      - -
      + + + +

      {user.username}

      {/*

      410 followers

      */} -
      +
      ); }; From b3bf329846156595738c718f7393c2aa68c45460 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Mon, 3 Mar 2025 14:49:26 -0300 Subject: [PATCH 34/56] feat: add link to user profile in song card widget --- web/src/modules/browse/components/SongCard.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/src/modules/browse/components/SongCard.tsx b/web/src/modules/browse/components/SongCard.tsx index ef5d4305..a280d432 100644 --- a/web/src/modules/browse/components/SongCard.tsx +++ b/web/src/modules/browse/components/SongCard.tsx @@ -48,9 +48,16 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { {!song ? ( ) : ( - `${song.uploader.username} • ${formatTimeAgo( - new Date(song.createdAt), - )}` + <> + + {song.uploader.username} + + {' • '} + {formatTimeAgo(new Date(song.createdAt))} + )}

      {/* Play icon & count */} From 6c00f79eeb62cf9439e15f5ee228365d43ca5fee Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 00:47:44 -0300 Subject: [PATCH 35/56] feat: add filter by user in song controller GET method --- server/src/song/song.service.ts | 21 ++++++++++++++++++- server/src/user/entity/user.entity.ts | 4 +++- shared/validation/common/dto/PageQuery.dto.ts | 9 ++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index f62f30b5..557a2232 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -18,6 +18,7 @@ import { Model } from 'mongoose'; import { FileService } from '@server/file/file.service'; import { UserDocument } from '@server/user/entity/user.entity'; +import { UserService } from '@server/user/user.service'; import { Song as SongEntity, SongWithUser } from './entity/song.entity'; import { SongUploadService } from './song-upload/song-upload.service'; @@ -40,6 +41,9 @@ export class SongService { @Inject(SongWebhookService) private songWebhookService: SongWebhookService, + + @Inject(UserService) + private userService: UserService, ) {} public async uploadSong({ @@ -177,7 +181,7 @@ export class SongService { } public async getSongByPage(query: PageQueryDTO): Promise { - const { page, limit, sort, order } = query; + const { page, limit, sort, order, user } = query; if (!page || !limit || !sort) { throw new HttpException( @@ -186,9 +190,24 @@ export class SongService { ); } + let filter = {}; + + if (user) { + const userDocument = await this.userService.findByUsername(user); + + if (!userDocument) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + filter = { + uploader: userDocument._id, + }; + } + const songs = (await this.songModel .find({ visibility: 'public', + ...filter, }) .sort({ [sort]: order ? 1 : -1, diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index a3db6c15..a6a19eb1 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; +import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose'; @Schema({ timestamps: true, @@ -71,6 +71,8 @@ export class User { @Prop({ type: Boolean, required: true, default: true }) prefersDarkTheme: boolean; + _id: Types.ObjectId; + createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html diff --git a/shared/validation/common/dto/PageQuery.dto.ts b/shared/validation/common/dto/PageQuery.dto.ts index b1ea53d3..8bf15a56 100644 --- a/shared/validation/common/dto/PageQuery.dto.ts +++ b/shared/validation/common/dto/PageQuery.dto.ts @@ -64,6 +64,15 @@ export class PageQueryDTO { }) timespan?: TimespanType; + @IsString() + @IsOptional() + @ApiProperty({ + example: 'Bentroen', + description: 'Filters results uploaded by a specific user (by username).', + required: false, + }) + user?: string; + constructor(partial: Partial) { Object.assign(this, partial); } From 6c663a7190b20b0e5c1883b0c4750e32f949d3bd Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 00:49:16 -0300 Subject: [PATCH 36/56] fix: return 404 if user not found instead of malformed request error --- server/src/user/user.service.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 7fa85e50..6bb4c62b 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -146,23 +146,21 @@ export class UserService { if (email) { user = await this.findByEmail(email); - } - - if (id) { + } else if (id) { user = await this.findByID(id); - } - - if (username) { + } else if (username) { user = await this.findByUsername(username); - } - - if (!user) { + } else { throw new HttpException( 'You must provide an email, ID or username', HttpStatus.BAD_REQUEST, ); } + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + return UserProfileViewDto.fromUserDocument(user); } From 1ebde28aca82a2749ecb9c2ae9deaa63b8102f53 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 00:50:05 -0300 Subject: [PATCH 37/56] style: add comment, whitespace --- server/src/user/entity/user.entity.ts | 2 +- server/src/user/user.controller.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index a6a19eb1..73154f0a 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -74,7 +74,7 @@ export class User { _id: Types.ObjectId; createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html - + updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html } diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 2d75e212..031e87b1 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -30,6 +30,7 @@ export class UserController { @Get('/:username') async getUser(@Param('username') username: string) { return await this.userService.getUserByEmailOrId({ username: username }); + // TODO: this may call userService.getUserByUsername directly } @Get('by-query') From 06ca7641d1353dcf1cafc3321a98edbeac1b269e Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 00:50:56 -0300 Subject: [PATCH 38/56] feat: add user page layout with initial user song fetching --- web/src/app/(content)/user/[id]/page.tsx | 23 ---- .../app/(content)/user/[username]/page.tsx | 33 +++++ .../modules/user/components/UserBadges.tsx | 24 ++++ .../modules/user/components/UserProfile.tsx | 123 ++++++++++++------ .../user/components/UserSocialIcon.tsx | 53 ++++++++ web/src/modules/user/features/user.util.ts | 31 ++++- 6 files changed, 220 insertions(+), 67 deletions(-) delete mode 100644 web/src/app/(content)/user/[id]/page.tsx create mode 100644 web/src/app/(content)/user/[username]/page.tsx create mode 100644 web/src/modules/user/components/UserBadges.tsx create mode 100644 web/src/modules/user/components/UserSocialIcon.tsx diff --git a/web/src/app/(content)/user/[id]/page.tsx b/web/src/app/(content)/user/[id]/page.tsx deleted file mode 100644 index ce64d307..00000000 --- a/web/src/app/(content)/user/[id]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ErrorBox } from '@web/src/modules/shared/components/client/ErrorBox'; -import UserProfile from '@web/src/modules/user/components/UserProfile'; -import { getUserProfileData } from '@web/src/modules/user/features/user.util'; - -const UserPage = async ({ params }: { params: { id: string } }) => { - const { id } = params; - - let userData; - - try { - userData = await getUserProfileData(id); - } catch { - userData = null; - } - - return !userData ? ( - - ) : ( - - ); -}; - -export default UserPage; diff --git a/web/src/app/(content)/user/[username]/page.tsx b/web/src/app/(content)/user/[username]/page.tsx new file mode 100644 index 00000000..3ad80872 --- /dev/null +++ b/web/src/app/(content)/user/[username]/page.tsx @@ -0,0 +1,33 @@ +import { ErrorBox } from '@web/src/modules/shared/components/client/ErrorBox'; +import UserProfile from '@web/src/modules/user/components/UserProfile'; +import { + getUserProfileData, + getUserSongs, +} from '@web/src/modules/user/features/user.util'; + +const UserPage = async ({ params }: { params: { username: string } }) => { + const { username } = params; + + let userData = null; + let songData = null; + + try { + userData = await getUserProfileData(username); + } catch (e) { + console.error('Failed to get user data:', e); + } + + try { + songData = await getUserSongs(username); + } catch (e) { + console.error('Failed to get song data:', e); + } + + return !userData ? ( + + ) : ( + + ); +}; + +export default UserPage; diff --git a/web/src/modules/user/components/UserBadges.tsx b/web/src/modules/user/components/UserBadges.tsx new file mode 100644 index 00000000..78684ae0 --- /dev/null +++ b/web/src/modules/user/components/UserBadges.tsx @@ -0,0 +1,24 @@ +import { faRocket, faSquareFull } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +export const EarlySupporterBadge = () => { + return ( + + + + +

      + Early Supporter +

      +
      +
      + ); +}; diff --git a/web/src/modules/user/components/UserProfile.tsx b/web/src/modules/user/components/UserProfile.tsx index 358daaee..5e32a446 100644 --- a/web/src/modules/user/components/UserProfile.tsx +++ b/web/src/modules/user/components/UserProfile.tsx @@ -1,50 +1,97 @@ +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import Image from 'next/image'; +import { EarlySupporterBadge } from './UserBadges'; +import UserSocialIcon from './UserSocialIcon'; +import SongCard from '../../browse/components/SongCard'; +import SongCardGroup from '../../browse/components/SongCardGroup'; +import { formatTimeAgo } from '../../shared/util/format'; + type UserProfileProps = { userData: UserProfileViewDto; + songData: SongPreviewDto[] | null; }; -const UserProfile = ({ userData }: UserProfileProps) => { - const { - lastSeen, - loginStreak, - playCount, - publicName, - description, - profileImage, - // socialLinks, - } = userData; +const UserProfile = ({ userData, songData }: UserProfileProps) => { + const { lastSeen, username, description, profileImage } = userData; return ( -
      - {publicName} -

      {publicName}

      -

      {description}

      -

      Last Login: {lastSeen.toLocaleString()}

      -

      Login Streak: {loginStreak}

      -

      Play Count: {playCount}

      - {/*
        - {Object.keys(socialLinks).map((key, index) => { - const link = socialLinks[key as keyof UserLinks]; - if (!link) return null; - - return ( -
      • - - {key} - -
      • - ); - })} -
      */} -
      +
      + {/* HEADER */} +
      +
      + {username} +
      + {/* Display name */} +
      +

      {username}

      + +
      + + {/* Username/handle */} +

      + {`@${username}`} + {` • 5 songs • 2,534 plays`} +

      + + {/* Description */} +

      + Hello! This is my user description. +

      + + {/* Social links */} +
      + + + + + +
      +
      +
      +
      + {/* Joined */} +

      Joined

      +

      + {/* TODO: lastSeen is supposed to be a date, but it's a string */} + {new Date(lastSeen).toLocaleDateString('en-UK')} + {` (${formatTimeAgo( + new Date(lastSeen), + )})`} +

      + + {/* Last seen */} +

      Last seen

      +

      + {/* TODO: lastSeen is supposed to be a date, but it's a string */} + {new Date(lastSeen).toLocaleDateString('en-UK')} + {` (${formatTimeAgo( + new Date(lastSeen), + )})`} +

      +
      +
      +
      + +
      + + {/* UPLOADED SONGS */} +
      +

      Songs

      + + {songData?.map((song, i) => ( + + ))} + +
      +
      ); }; diff --git a/web/src/modules/user/components/UserSocialIcon.tsx b/web/src/modules/user/components/UserSocialIcon.tsx new file mode 100644 index 00000000..e3731dec --- /dev/null +++ b/web/src/modules/user/components/UserSocialIcon.tsx @@ -0,0 +1,53 @@ +import { + IconDefinition, + faBandcamp, + faDiscord, + faFacebook, + faGithub, + faInstagram, + faLinkedin, + faPatreon, + faPinterest, + faReddit, + faSnapchat, + faSoundcloud, + faSpotify, + faSteam, + faTiktok, + faTwitch, + faXTwitter, + faYoutube, +} from '@fortawesome/free-brands-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Link from 'next/link'; + +const iconLookup: Record = { + twitter: faXTwitter, + youtube: faYoutube, + github: faGithub, + discord: faDiscord, + bandcamp: faBandcamp, + soundcloud: faSoundcloud, + instagram: faInstagram, + facebook: faFacebook, + patreon: faPatreon, + twitch: faTwitch, + spotify: faSpotify, + tiktok: faTiktok, + linkedin: faLinkedin, + snapchat: faSnapchat, + pinterest: faPinterest, + reddit: faReddit, + steam: faSteam, +}; + +const UserSocialIcon = ({ icon, href }: { href: string; icon: string }) => ( + + + +); + +export default UserSocialIcon; diff --git a/web/src/modules/user/features/user.util.ts b/web/src/modules/user/features/user.util.ts index 1c4efabc..0d8b1211 100644 --- a/web/src/modules/user/features/user.util.ts +++ b/web/src/modules/user/features/user.util.ts @@ -1,14 +1,33 @@ +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; + import axiosInstance from '../../../lib/axios'; -import { UserProfileData } from '../../auth/types/User'; -export const getUserProfileData = async ( - id: string, -): Promise => { +export const getUserProfileData = async (username: string) => { try { - const res = await axiosInstance.get(`/user/${id}`); - if (res.status === 200) return res.data as UserProfileData; + const res = await axiosInstance.get( + `/user/${username}`, + ); + + if (res.status === 200) return res.data; else throw new Error('Failed to get user data'); } catch { throw new Error('Failed to get user data'); } }; + +export const getUserSongs = async (username: string) => { + try { + const res = await axiosInstance.get(`/song`, { + params: { + limit: 12, + user: username, + }, + }); + + if (res.status === 200) return res.data; + else throw new Error('Failed to get user songs'); + } catch { + throw new Error('Failed to get user songs'); + } +}; From 8cc0bcb980d5383a387e550cbdce1b3fb6a4e4f7 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 00:52:09 -0300 Subject: [PATCH 39/56] chore: adjust eslint settings to avoid hanging during lint --- .eslintrc.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 1b765e7a..9cada1ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,10 +2,11 @@ module.exports = { ignorePatterns: ['.eslintrc.js'], parserOptions: { parser: '@typescript-eslint/parser', - project: './tsconfig.json', + project: ['./tsconfig.json', './*/tsconfig.json'], tsconfigRootDir: __dirname, sourceType: 'module', ecmaVersion: 2021, + createDefaultProgram: false, }, plugins: ['@typescript-eslint', 'prettier', 'import', 'unused-imports'], // Merged plugins from both files extends: [ From f4a73ed62b7a5503be35040d0d2328a34c1c7d38 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 00:58:53 -0300 Subject: [PATCH 40/56] chore: update `.git-blame-ignore-revs` --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 88c7be3f..5a8fa87c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,3 +3,5 @@ # Linting commits 6efc953b9f7f648f2be59295a78ce1180f12b32d +f52bdcb555ffa1ed02ae8918b04500c9cee52bfd +23b7f2d5ca99b69ea1e50667fdb9c9795d55195f From f8244c90013eac3b5990d09a0fb41bdc1ef85020 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Wed, 5 Mar 2025 22:17:45 -0300 Subject: [PATCH 41/56] fix: ad filling error causing frontend crash --- web/src/modules/shared/components/client/ads/AdSlots.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/modules/shared/components/client/ads/AdSlots.tsx b/web/src/modules/shared/components/client/ads/AdSlots.tsx index 73fa60a6..a8c1f377 100644 --- a/web/src/modules/shared/components/client/ads/AdSlots.tsx +++ b/web/src/modules/shared/components/client/ads/AdSlots.tsx @@ -56,7 +56,11 @@ const AdTemplate = ({ useEffect(() => { if (window) { - (window.adsbygoogle = window.adsbygoogle || []).push({}); + try { + (window.adsbygoogle = window.adsbygoogle || []).push({}); + } catch (e) { + console.error(e); + } } }, []); From 67203cecadf849bf191c1aee0cb6d0657b2c5d2e Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Fri, 7 Mar 2025 15:21:07 -0300 Subject: [PATCH 42/56] fix: hydration error due to nested `` tag in `SongCard` component --- web/src/modules/browse/components/SongCard.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/modules/browse/components/SongCard.tsx b/web/src/modules/browse/components/SongCard.tsx index a280d432..3d34c172 100644 --- a/web/src/modules/browse/components/SongCard.tsx +++ b/web/src/modules/browse/components/SongCard.tsx @@ -4,6 +4,7 @@ import { faPlay } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SongPreviewDtoType } from '@shared/validation/song/dto/types'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import Skeleton from 'react-loading-skeleton'; import { @@ -14,6 +15,8 @@ import { import SongThumbnail from '../../shared/components/layout/SongThumbnail'; const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { + const router = useRouter(); + return (
      {/* Song image */} @@ -49,12 +52,18 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { ) : ( <> - tag can't be nested. Figure out a better way to arrange them (likely + place a link in the image, title and each of the card's components) */} + {' • '} {formatTimeAgo(new Date(song.createdAt))} From b24f421b3d5bb747910dc46e3525b1374f7d359f Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Fri, 7 Mar 2025 16:13:15 -0300 Subject: [PATCH 43/56] test: fix `SongService` tests for new query by user method --- server/src/song/song.service.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/song/song.service.spec.ts b/server/src/song/song.service.spec.ts index 48536450..e5416761 100644 --- a/server/src/song/song.service.spec.ts +++ b/server/src/song/song.service.spec.ts @@ -10,6 +10,7 @@ import mongoose, { Model } from 'mongoose'; import { FileService } from '@server/file/file.service'; import { UserDocument } from '@server/user/entity/user.entity'; +import { UserService } from '@server/user/user.service'; import { SongDocument, @@ -39,10 +40,17 @@ const mockSongWebhookService = { syncSongWebhook: jest.fn(), }; +const mockUserService = { + getUserByEmailOrId: jest.fn(), + getUserPaginated: jest.fn(), + getSelfUserData: jest.fn(), +}; + describe('SongService', () => { let service: SongService; let fileService: FileService; let songUploadService: SongUploadService; + let userService: UserService; let songModel: Model; beforeEach(async () => { @@ -65,12 +73,17 @@ describe('SongService', () => { provide: SongUploadService, useValue: mockSongUploadService, }, + { + provide: UserService, + useValue: mockUserService, + }, ], }).compile(); service = module.get(SongService); fileService = module.get(FileService); songUploadService = module.get(SongUploadService); + userService = module.get(UserService); songModel = module.get>(getModelToken(SongEntity.name)); }); From 753a933572c7a1246923bd020bc1d38737d56c44 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Fri, 7 Mar 2025 16:16:05 -0300 Subject: [PATCH 44/56] refactor: tidy up API methods for `UserController` --- server/src/user/user.controller.ts | 52 +++++++-------------- web/src/modules/auth/features/auth.utils.ts | 4 +- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 031e87b1..a673e66e 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -9,9 +9,8 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; -import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; -import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; +import { UserQuery } from '@shared/validation/user/dto/UserQuery.dto'; import { GetRequestToken, validateUser } from '@server/GetRequestUser'; @@ -27,44 +26,29 @@ export class UserController { private readonly userService: UserService, ) {} - @Get('/:username') - async getUser(@Param('username') username: string) { - return await this.userService.getUserByEmailOrId({ username: username }); - // TODO: this may call userService.getUserByUsername directly - } - - @Get('by-query') - async getUserByQuery(@Query() query: GetUser) { - return await this.userService.getUserByEmailOrId(query); - } - - @Get('paginated') - async getUserPaginated(@Query() query: PageQueryDTO) { - return await this.userService.getUserPaginated(query); - } - - @Get('me') + @Get() @ApiTags('user') - @ApiBearerAuth() - @ApiOperation({ summary: 'Get the token owner data' }) - async getMe(@GetRequestToken() user: UserDocument | null) { - user = validateUser(user); - return await this.userService.getSelfUserData(user); + @ApiOperation({ summary: 'Get user data' }) + async getUser( + @Query() query: UserQuery, + @GetRequestToken() user: UserDocument | null, + ) { + if ('me' in query && query.me) { + user = validateUser(user); + return await this.userService.getSelfUserData(user); + } + + return await this.userService.getUserPaginated(query as PageQueryDTO); } - @Patch('username') + @Get(':username') @ApiTags('user') - @ApiBearerAuth() - @ApiOperation({ summary: 'Update the username' }) - async updateUsername( - @GetRequestToken() user: UserDocument | null, - @Body() body: UpdateUsernameDto, - ) { - user = validateUser(user); - return await this.userService.updateUsername(user, body); + @ApiOperation({ summary: 'Get user profile by username' }) + async getUserProfile(@Param('username') username: string) { + return await this.userService.findByUsername(username); } - @Patch('profile') + @Patch() @ApiTags('user') @ApiBearerAuth() @ApiOperation({ summary: 'Update the profile' }) diff --git a/web/src/modules/auth/features/auth.utils.ts b/web/src/modules/auth/features/auth.utils.ts index 17c3f459..37ecab45 100644 --- a/web/src/modules/auth/features/auth.utils.ts +++ b/web/src/modules/auth/features/auth.utils.ts @@ -40,9 +40,11 @@ export const getUserData = async (): Promise => { if (!token) throw new Error('No token found'); if (!token.value) throw new Error('No token found'); + let res; + try { // verify the token with the server - const res = await axiosInstance.get('/user/me', { + res = await axiosInstance.get('/user?me=true', { headers: { authorization: `Bearer ${token.value}`, }, From e48b09857e9f575ca6d64b9ac94ac9a3cc7fed31 Mon Sep 17 00:00:00 2001 From: Bernardo Costa Date: Sat, 8 Mar 2025 15:35:07 -0300 Subject: [PATCH 45/56] chore: add `zustand` to dependencies --- pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ web/package.json | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ce1134..9821555f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,9 @@ importers: zod-validation-error: specifier: ^3.4.0 version: 3.4.0(zod@3.24.1) + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@18.2.18)(react@18.2.0) devDependencies: '@shrutibalasa/tailwind-grid-auto-fit': specifier: ^1.1.0 @@ -6306,6 +6309,24 @@ packages: zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -13944,4 +13965,9 @@ snapshots: zod@3.24.1: {} + zustand@5.0.3(@types/react@18.2.18)(react@18.2.0): + optionalDependencies: + '@types/react': 18.2.18 + react: 18.2.0 + zwitch@2.0.4: {} diff --git a/web/package.json b/web/package.json index 5355eb6b..ef98617f 100644 --- a/web/package.json +++ b/web/package.json @@ -54,7 +54,8 @@ "tailwindcss-animate": "^1.0.7", "typescript": "^5.1.3", "zod": "^3.24.1", - "zod-validation-error": "^3.4.0" + "zod-validation-error": "^3.4.0", + "zustand": "^5.0.3" }, "devDependencies": { "@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0", From 643468128c9ef47726904b6b687141277d4576c9 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 15 Mar 2025 12:53:55 -0300 Subject: [PATCH 46/56] feat: update PageQueryDTO to use 'query' for filtering and add UserQuery DTO for user-specific queries --- shared/validation/common/dto/PageQuery.dto.ts | 7 ++++--- shared/validation/user/dto/UserQuery.dto.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 shared/validation/user/dto/UserQuery.dto.ts diff --git a/shared/validation/common/dto/PageQuery.dto.ts b/shared/validation/common/dto/PageQuery.dto.ts index 8bf15a56..8004f843 100644 --- a/shared/validation/common/dto/PageQuery.dto.ts +++ b/shared/validation/common/dto/PageQuery.dto.ts @@ -67,11 +67,12 @@ export class PageQueryDTO { @IsString() @IsOptional() @ApiProperty({ - example: 'Bentroen', - description: 'Filters results uploaded by a specific user (by username).', + examples: ['Bentroen', 'Tomast1337', 'Slayer - Raining Blood'], + description: + 'Filters results uploaded by a string matching the specified query.', required: false, }) - user?: string; + query?: string; constructor(partial: Partial) { Object.assign(this, partial); diff --git a/shared/validation/user/dto/UserQuery.dto.ts b/shared/validation/user/dto/UserQuery.dto.ts new file mode 100644 index 00000000..c4f3840a --- /dev/null +++ b/shared/validation/user/dto/UserQuery.dto.ts @@ -0,0 +1,10 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { IsBoolean, IsOptional } from 'class-validator'; + +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; + +export class UserQuery extends PartialType(PageQueryDTO) { + @IsBoolean() + @IsOptional() + me?: boolean; +} From 820d6b381bd72c447cc03f36ad91b5c087d08335 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 15 Mar 2025 12:53:59 -0300 Subject: [PATCH 47/56] feat: replace old SearchPage component with new SearchPage implementation using SearchPageComponent --- web/src/app/(content)/search/[search]/page.tsx | 13 ------------- web/src/app/(content)/search/page.tsx | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 13 deletions(-) delete mode 100644 web/src/app/(content)/search/[search]/page.tsx create mode 100644 web/src/app/(content)/search/page.tsx diff --git a/web/src/app/(content)/search/[search]/page.tsx b/web/src/app/(content)/search/[search]/page.tsx deleted file mode 100644 index 8c03535a..00000000 --- a/web/src/app/(content)/search/[search]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -interface SearchPage { - params: { - search: string; - }; -} - -const Page = ({ params }: SearchPage) => { - const { search } = params; - - return <>search: {search}; -}; - -export default Page; diff --git a/web/src/app/(content)/search/page.tsx b/web/src/app/(content)/search/page.tsx new file mode 100644 index 00000000..b96ce76c --- /dev/null +++ b/web/src/app/(content)/search/page.tsx @@ -0,0 +1,16 @@ +import { SearchPageComponent } from '@web/src/modules/search/components/SearchPageComponent'; + +interface SearchPageProps { + searchParams: { + query?: string; + page?: string; + limit?: string; + category?: string; + }; +} + +export default async function SearchPage({ searchParams }: SearchPageProps) { + console.log(searchParams); + + return ; +} From 1f445fe2d21f63578060d1ebda72e6a8ac9ffe53 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 15 Mar 2025 12:54:04 -0300 Subject: [PATCH 48/56] feat: implement SearchPageComponent and useSearch context for search functionality --- .../search/components/SearchPageComponent.tsx | 52 ++++++++++++++++++ .../components/client/context/useSearch.tsx | 53 +++++++++++++++++++ web/src/modules/search/context/search.ts | 0 .../components/layout/BlockSearchProps.tsx | 10 +++- 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 web/src/modules/search/components/SearchPageComponent.tsx create mode 100644 web/src/modules/search/components/client/context/useSearch.tsx create mode 100644 web/src/modules/search/context/search.ts diff --git a/web/src/modules/search/components/SearchPageComponent.tsx b/web/src/modules/search/components/SearchPageComponent.tsx new file mode 100644 index 00000000..2969634e --- /dev/null +++ b/web/src/modules/search/components/SearchPageComponent.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; + +import { useSearch } from './client/context/useSearch'; + +export const SearchPageComponent = () => { + const searchParams = useSearchParams(); + const { results, query, fetchSearchResults } = useSearch(); + + useEffect(() => { + const query = searchParams.get('query') || ''; + const page = searchParams.get('page') || '1'; + const limit = searchParams.get('limit') || '20'; + + fetchSearchResults(query, parseInt(page), parseInt(limit)); + }, []); + + return ( + <> + {/* Search Header */} +
      + +
      + +

      + Search Results +

      + {query && ( +

      + {`Showing results for "${query}"`} +

      + )} + + {results.length === 0 ? ( +
      + No results found. Try searching for something else. +
      + ) : ( +
      + {JSON.stringify(results, null, 2)} +
      + )} + + ); +}; diff --git a/web/src/modules/search/components/client/context/useSearch.tsx b/web/src/modules/search/components/client/context/useSearch.tsx new file mode 100644 index 00000000..5d8e6636 --- /dev/null +++ b/web/src/modules/search/components/client/context/useSearch.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { create } from 'zustand'; + +import axios from '@web/src/lib/axios'; + +type SearchState = { + fetchSearchResults: ( + query: string, + page: number, + limit: number, + ) => Promise; + query: string; + page: number; + limit: number; + results: any[]; + isLoading: boolean; +}; + +export const useSearch = create((set, get) => { + const fetchSearchResults = async ( + query: string, + page: number, + limit: number, + ) => { + set({ isLoading: true }); + + const result = await axios.get('/user', { + params: { + query: query, + page: page, + limit: limit, + }, + }); + + set({ + query, + page, + limit, + results: result.data, + isLoading: false, + }); + }; + + return { + fetchSearchResults, + query: '', + page: 1, + limit: 20, + results: [], + isLoading: false, + }; +}); diff --git a/web/src/modules/search/context/search.ts b/web/src/modules/search/context/search.ts new file mode 100644 index 00000000..e69de29b diff --git a/web/src/modules/shared/components/layout/BlockSearchProps.tsx b/web/src/modules/shared/components/layout/BlockSearchProps.tsx index d88bf7c5..a17e4cfe 100644 --- a/web/src/modules/shared/components/layout/BlockSearchProps.tsx +++ b/web/src/modules/shared/components/layout/BlockSearchProps.tsx @@ -1,6 +1,7 @@ 'use client'; import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { cn } from '@web/src/lib/tailwind.utils'; @@ -9,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from './popover'; export const BlockSearch = () => { const [query, setQuery] = useState(''); + const router = useRouter(); return ( @@ -33,7 +35,13 @@ export const BlockSearch = () => { + +
      + )} ); diff --git a/web/src/modules/search/components/client/context/useSearch.tsx b/web/src/modules/search/components/client/context/useSearch.tsx index 5d8e6636..77fa4198 100644 --- a/web/src/modules/search/components/client/context/useSearch.tsx +++ b/web/src/modules/search/components/client/context/useSearch.tsx @@ -1,5 +1,7 @@ 'use client'; +import { PageResultDTO } from '@shared/validation/common/dto/PageResult.dto'; +import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto'; import { create } from 'zustand'; import axios from '@web/src/lib/axios'; @@ -13,11 +15,11 @@ type SearchState = { query: string; page: number; limit: number; - results: any[]; + data: UserSearchViewDto[]; isLoading: boolean; }; -export const useSearch = create((set, get) => { +export const useSearch = create((set) => { const fetchSearchResults = async ( query: string, page: number, @@ -25,7 +27,7 @@ export const useSearch = create((set, get) => { ) => { set({ isLoading: true }); - const result = await axios.get('/user', { + const result = await axios.get>('/user', { params: { query: query, page: page, @@ -33,11 +35,13 @@ export const useSearch = create((set, get) => { }, }); + const { data } = result; + set({ - query, - page, - limit, - results: result.data, + query: query, + page: data.page, + limit: data.limit, + data: data.data, isLoading: false, }); }; @@ -47,7 +51,7 @@ export const useSearch = create((set, get) => { query: '', page: 1, limit: 20, - results: [], + data: [], isLoading: false, }; }); diff --git a/web/src/modules/shared/components/layout/BlockSearchProps.tsx b/web/src/modules/shared/components/layout/BlockSearchProps.tsx index a17e4cfe..01f82a58 100644 --- a/web/src/modules/shared/components/layout/BlockSearchProps.tsx +++ b/web/src/modules/shared/components/layout/BlockSearchProps.tsx @@ -41,7 +41,7 @@ export const BlockSearch = () => { query, }); - router.push(`/search?${queryParam.toString()}`); + router.push(`/search-user?${queryParam.toString()}`); }} > From ff9a54390494836068c08b36c8f3e60619859084 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 15 Mar 2025 16:01:59 -0300 Subject: [PATCH 55/56] feat: integrate router for search results navigation and update query parameters --- .../search/components/SearchPageComponent.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/web/src/modules/search/components/SearchPageComponent.tsx b/web/src/modules/search/components/SearchPageComponent.tsx index f15752b1..de41cd7d 100644 --- a/web/src/modules/search/components/SearchPageComponent.tsx +++ b/web/src/modules/search/components/SearchPageComponent.tsx @@ -4,7 +4,7 @@ import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto'; import Image from 'next/image'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useSearch } from './client/context/useSearch'; @@ -69,8 +69,9 @@ export const SearchPageComponent = () => { const searchParams = useSearchParams(); const [currentPage, setCurrentPage] = useState(1); - const { data, query, isLoading, limit, page, fetchSearchResults } = - useSearch(); + const { data, query, isLoading, limit, fetchSearchResults } = useSearch(); + + const router = useRouter(); useEffect(() => { const query = searchParams.get('query') || ''; @@ -86,8 +87,13 @@ export const SearchPageComponent = () => { const query = searchParams.get('query') || ''; const limit = searchParams.get('limit') || '20'; - fetchSearchResults(query, newPage, parseInt(limit)); - setCurrentPage(newPage); + const queryParam = new URLSearchParams({ + page: newPage.toString(), + limit: limit, + query, + }); + + router.push(`/search-user?${queryParam.toString()}`); }; return ( From 09a8a6dc19e3625f7dd4266ea58a9dfd4d5bb716 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sat, 15 Mar 2025 16:03:25 -0300 Subject: [PATCH 56/56] feat: update user profile page title and enable navigation from user cards --- web/src/app/(content)/user/[username]/page.tsx | 2 +- web/src/modules/search/components/SearchPageComponent.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/app/(content)/user/[username]/page.tsx b/web/src/app/(content)/user/[username]/page.tsx index ff41c9c0..7cdb1117 100644 --- a/web/src/app/(content)/user/[username]/page.tsx +++ b/web/src/app/(content)/user/[username]/page.tsx @@ -25,7 +25,7 @@ const UserPage = async ({ params }: { params: { username: string } }) => { if (userData) { // set the page title to the user's name - document.title = `${userData?.publicName} - User Profile`; + return ; } else { return ; diff --git a/web/src/modules/search/components/SearchPageComponent.tsx b/web/src/modules/search/components/SearchPageComponent.tsx index de41cd7d..564cd533 100644 --- a/web/src/modules/search/components/SearchPageComponent.tsx +++ b/web/src/modules/search/components/SearchPageComponent.tsx @@ -15,9 +15,12 @@ type UserCardProps = { export const UserCard = ({ user }: UserCardProps) => { const { id, profileImage, songCount, username } = user; - + const router = useRouter(); return ( -
      +
      router.push(`/user/${id}`)} + > {/* Profile Image */}