diff --git a/.eslintrc.js b/.eslintrc.js index 675f56e9..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: [ @@ -36,8 +37,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/.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 diff --git a/NoteBlockWorld.code-workspace b/NoteBlockWorld.code-workspace index 215a4b08..017bf650 100644 --- a/NoteBlockWorld.code-workspace +++ b/NoteBlockWorld.code-workspace @@ -1,37 +1,48 @@ { - "folders": [ - { - "path": ".", - "name": "Root" + "folders": [ + { + "path": ".", + "name": "Root" + }, + { + "path": "./server", + "name": "Backend" + }, + { + "path": "./shared", + "name": "Shared" + }, + { + "path": "./web", + "name": "Frontend" + } + ], + "settings": { + "window.title": "${dirty}${rootName}${separator}${profileName}${separator}${appName}", + "editor.formatOnSave": true, + "eslint.validate": [ + "typescript" + ], + "eslint.run": "onType", + "eslint.format.enable": true, + "mdx.server.enable": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "jest.disabledWorkspaceFolders": [ + "Root", + "Frontend" + ], + "search.exclude": { + "**/.git": true, + "**/node_modules": true, + "**/dist": true, + }, + "cSpell.words": [ + "Bentroen", + "spotify", + "tiktok", + "Tomast" + ] }, - { - "path": "./server", - "name": "Backend" - }, - { - "path": "./shared", - "name": "Shared" - }, - { - "path": "./web", - "name": "Frontend" - } - ], - "settings": { - "window.title": "${dirty}${rootName}${separator}${profileName}${separator}${appName}", - "editor.formatOnSave": true, - "eslint.validate": ["typescript"], - "eslint.run": "onType", - "eslint.format.enable": true, - "mdx.server.enable": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "jest.disabledWorkspaceFolders": ["Root", "Frontend"], - "search.exclude": { - "**/.git": true, - "**/node_modules": true, - "**/dist": true, - } - } -} +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index a0f2bc58..e39addf7 100644 --- a/bun.lock +++ b/bun.lock @@ -170,6 +170,7 @@ "typescript": "^5.1.3", "zod": "^3.24.1", "zod-validation-error": "^3.4.0", + "zustand": "^5.0.4", }, "devDependencies": { "@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0", @@ -3102,6 +3103,8 @@ "zod-validation-error": ["zod-validation-error@3.4.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ=="], + "zustand": ["zustand@5.0.4", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], diff --git a/package.json b/package.json index a61fb975..a2726413 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,6 @@ }, "dependencies": { "ts-node": "^10.9.1" - } + }, + "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" } diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 66e29486..7a4c430f 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -22,6 +22,7 @@ import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; @ApiTags('auth') export class AuthController { private readonly logger = new Logger(AuthController.name); + constructor( @Inject(AuthService) private readonly authService: AuthService, diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 52c8ffac..8bdcadbc 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 ebc6d905..b5efb51a 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/entity/song.entity.ts b/server/src/song/entity/song.entity.ts index 530ca19e..ce10c5b2 100644 --- a/server/src/song/entity/song.entity.ts +++ b/server/src/song/entity/song.entity.ts @@ -59,7 +59,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 }) @@ -74,13 +74,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.spec.ts b/server/src/song/song.service.spec.ts index f18f389d..00beae1a 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 type { 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)); }); diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 4f4a9fcd..abc479af 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'; @@ -17,6 +18,7 @@ import { Model } from 'mongoose'; import { FileService } from '@server/file/file.service'; import type { 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'; @@ -26,6 +28,7 @@ import { removeExtraSpaces } from './song.util'; @Injectable() export class SongService { private logger = new Logger(SongService.name); + constructor( @InjectModel(SongEntity.name) private songModel: Model, @@ -38,6 +41,9 @@ export class SongService { @Inject(SongWebhookService) private songWebhookService: SongWebhookService, + + @Inject(UserService) + private userService: UserService, ) {} public async getSongById(publicId: string) { @@ -190,9 +196,27 @@ export class SongService { ); } + const filter = {}; + + /* + // TODO: Decide if user filtering is necessary + 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, @@ -478,7 +502,135 @@ 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: SongViewDto[] = await this.songModel.aggregate([ + { + $match: { + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + ...(category && { 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 totalResult = await this.songModel.aggregate([ + { + /** + $search: { + index: 'song_search_index', + text: { + query: query, + }, + }, + */ + $match: { + $text: { + $search: query, + $caseSensitive: false, // Case-insensitive search + $diacriticSensitive: false, // Diacritic-insensitive search + }, + ...(category && { category: category }), + }, + }, + { + $count: 'total', + }, + ]); + + const total = totalResult.length > 0 ? totalResult[0].total : 0; + + this.logger.debug( + `Retrieved songs: ${songs.length} documents, with total: ${total}`, + ); + + 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', + }, + ); + } } 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/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index ff800a28..c42868d4 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -1,8 +1,8 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; +import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose'; @Schema({}) -class SocialLinks { +export class SocialLinks { bandcamp?: string; discord?: string; facebook?: string; @@ -53,10 +53,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 }) @@ -83,6 +83,12 @@ 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 } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/server/src/user/user.controller.spec.ts b/server/src/user/user.controller.spec.ts index 52cc71e5..8e365ea4 100644 --- a/server/src/user/user.controller.spec.ts +++ b/server/src/user/user.controller.spec.ts @@ -1,9 +1,5 @@ -import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; -import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; -import type { UserDocument } from './entity/user.entity'; import { UserController } from './user.controller'; import { UserService } from './user.service'; @@ -35,57 +31,4 @@ describe('UserController', () => { it('should be defined', () => { expect(userController).toBeDefined(); }); - - describe('getUser', () => { - it('should return user data by email or ID', async () => { - const query: GetUser = { - email: 'test@email.com', - username: 'test-username', - id: 'test-id', - }; - - const user = { email: 'test@example.com' }; - - mockUserService.getUserByEmailOrId.mockResolvedValueOnce(user); - - const result = await userController.getUser(query); - - expect(result).toEqual(user); - expect(userService.getUserByEmailOrId).toHaveBeenCalledWith(query); - }); - }); - - describe('getUserPaginated', () => { - it('should return paginated user data', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - const paginatedUsers = { items: [], total: 0 }; - - mockUserService.getUserPaginated.mockResolvedValueOnce(paginatedUsers); - - const result = await userController.getUserPaginated(query); - - expect(result).toEqual(paginatedUsers); - expect(userService.getUserPaginated).toHaveBeenCalledWith(query); - }); - }); - - describe('getMe', () => { - it('should return the token owner data', async () => { - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - const userData = { _id: 'test-user-id', email: 'test@example.com' }; - - mockUserService.getSelfUserData.mockResolvedValueOnce(userData); - - const result = await userController.getMe(user); - - expect(result).toEqual(userData); - expect(userService.getSelfUserData).toHaveBeenCalledWith(user); - }); - - it('should handle null user', async () => { - const user = null; - - await expect(userController.getMe(user)).rejects.toThrow(HttpException); - }); - }); }); diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 5780149f..78f0fabe 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,8 +1,20 @@ -import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Inject, + NotFoundException, + 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'; -import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; +import { PageResultDTO } from '@shared/validation/common/dto/PageResult.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; +import { UserQuery } from '@shared/validation/user/dto/UserQuery.dto'; +import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto'; import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; @@ -10,6 +22,8 @@ import type { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; @Controller('user') +@ApiTags('user') +@ApiBearerAuth() export class UserController { constructor( @Inject(UserService) @@ -18,36 +32,48 @@ export class UserController { @Get() @ApiTags('user') - @ApiBearerAuth() - async getUser(@Query() query: GetUser) { - return await this.userService.getUserByEmailOrId(query); - } + @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); + } - @Get() - @ApiTags('user') - @ApiBearerAuth() - async getUserPaginated(@Query() query: PageQueryDTO) { - return await this.userService.getUserPaginated(query); + const docs = await this.userService.getUserPaginated(query as PageQueryDTO); + + return new PageResultDTO({ + ...docs, + data: docs.data.map((doc) => UserSearchViewDto.fromUserDocument(doc)), + }); } - @Get('me') + @Get(':username') @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 profile by username' }) + async getUserProfile( + @Param('username') username: string, + ): Promise { + const doc = await this.userService.findByUsername(username); + + if (!doc) { + throw new NotFoundException('User not found'); + } + + return UserProfileViewDto.fromUserDocument(doc); } - @Patch('username') + @Patch() @ApiTags('user') @ApiBearerAuth() - @ApiOperation({ summary: 'Update the username' }) - async updateUsername( + @ApiOperation({ summary: 'Update the profile' }) + async updateProfile( @GetRequestToken() user: UserDocument | null, - @Body() body: UpdateUsernameDto, + @Body() body: UpdateUserProfileDto, ) { user = validateUser(user); - return await this.userService.updateUsername(user, body); + return await this.userService.updateProfile(user, body); } } diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 8477b011..9382b5f5 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -4,6 +4,7 @@ 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'; @@ -18,6 +19,7 @@ const mockUserModel = { exec: jest.fn(), select: jest.fn(), countDocuments: jest.fn(), + findOneAndUpdate: jest.fn(), }; describe('UserService', () => { @@ -519,4 +521,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 412da11b..87e38980 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,9 +1,12 @@ -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'; 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 { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; @@ -12,6 +15,8 @@ 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) { @@ -70,16 +75,8 @@ export class UserService { return user; } - public async findByPublicName( - publicName: string, - ): Promise { - const user = await this.userModel.findOne({ publicName }); - - return user; - } - public async findByUsername(username: string): Promise { - const user = await this.userModel.findOne({ username }); + const user = await this.userModel.findOne({ username }).exec(); return user; } @@ -87,16 +84,125 @@ export class UserService { public async getUserPaginated(query: PageQueryDTO) { const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; + const queryText = query.query; + const skip = (page - 1) * limit; const sortOrder = order === 'asc' ? 1 : -1; - const users = await this.userModel - .find({}) - .sort({ [sort]: sortOrder }) - .skip(skip) - .limit(limit); + const users: (UserDocument & { songCount: number })[] = + await this.userModel.aggregate([ + { + $match: queryText + ? { + username: { $regex: queryText, $options: 'i' }, // Case-insensitive regex search + } + : { + _id: { $exists: true }, + }, // If no search query, match all documents + }, + { + $lookup: { + from: 'songs', // The name of the songs collection + localField: '_id', // The field from the users collection + foreignField: 'userId', // The field from the songs collection + as: 'songs', // The array field that will contain the joined songs + }, + }, + { + $addFields: { + songCount: { $size: '$songs' }, // Add a new field with the count of songs + }, + }, + { + $project: { + songs: 0, // Exclude the songs array from the final output + }, + }, + { + $sort: { [sort]: sortOrder }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + ]); + + const total = await this.userModel.countDocuments( + queryText ? { username: { $regex: queryText, $options: 'i' } } : {}, + ); + + return { + data: users, + total, + page, + limit, + }; + } - const total = await this.userModel.countDocuments(); + 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: { + username: string; + profileImage: string; + }[] = await this.userModel.aggregate([ + { + $match: { + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + }, + }, + { + $project: { + username: 1, + profileImage: 1, + }, + }, + { + $sort: { [sort]: sortOrder }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + ]); + + const totalResult = await this.userModel.aggregate([ + { + $match: { + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + }, + }, + { + $count: 'total', + }, + ]); + + const total = totalResult.length > 0 ? totalResult[0].total : 0; + + this.logger.debug( + `Retrived users: ${users.length} documents, with total: ${total}`, + ); return { users, @@ -108,26 +214,26 @@ export class UserService { public async getUserByEmailOrId(query: GetUser) { const { email, id, username } = query; + let user; if (email) { - return await this.findByEmail(email); - } - - if (id) { - return await this.findByID(id); - } - - if (username) { + user = await this.findByEmail(email); + } else if (id) { + user = await this.findByID(id); + } else if (username) { + user = await this.findByUsername(username); + } else { throw new HttpException( - 'Username is not supported yet', + 'You must provide an email, ID or username', HttpStatus.BAD_REQUEST, ); } - throw new HttpException( - 'You must provide an email or an id', - HttpStatus.BAD_REQUEST, - ); + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + return UserProfileViewDto.fromUserDocument(user); } public async getHydratedUser(user: UserDocument) { @@ -226,4 +332,34 @@ export class UserService { return UserDto.fromEntity(user); } + + public async updateProfile(user: UserDocument, body: UpdateUserProfileDto) { + 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, + }); + } + + public async createSearchIndexes() { + return await this.userModel.collection.createIndex( + { + username: 'text', + publicName: 'text', + description: 'text', + }, + { + weights: { + username: 5, + publicName: 3, + description: 1, + }, + name: 'user_search_index', + }, + ); + } } 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); -} +}; diff --git a/shared/validation/common/dto/PageQuery.dto.ts b/shared/validation/common/dto/PageQuery.dto.ts index b1ea53d3..8004f843 100644 --- a/shared/validation/common/dto/PageQuery.dto.ts +++ b/shared/validation/common/dto/PageQuery.dto.ts @@ -64,6 +64,16 @@ export class PageQueryDTO { }) timespan?: TimespanType; + @IsString() + @IsOptional() + @ApiProperty({ + examples: ['Bentroen', 'Tomast1337', 'Slayer - Raining Blood'], + description: + 'Filters results uploaded by a string matching the specified query.', + required: false, + }) + query?: string; + constructor(partial: Partial) { Object.assign(this, partial); } diff --git a/shared/validation/common/dto/PageResult.dto.ts b/shared/validation/common/dto/PageResult.dto.ts new file mode 100644 index 00000000..28ea5484 --- /dev/null +++ b/shared/validation/common/dto/PageResult.dto.ts @@ -0,0 +1,11 @@ +export class PageResultDTO { + data: T[]; + page: number = 1; + total: number; + + limit: number; + + constructor(data: PageResultDTO) { + Object.assign(this, data); + } +} diff --git a/shared/validation/common/dto/SearchQuery.dto.ts b/shared/validation/common/dto/SearchQuery.dto.ts new file mode 100644 index 00000000..e78167b0 --- /dev/null +++ b/shared/validation/common/dto/SearchQuery.dto.ts @@ -0,0 +1,83 @@ +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.', + required: false, + }) + category?: string; + + @Min(1) + @ApiProperty({ + example: 1, + description: 'Page number.', + }) + page?: number; + + @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.', + required: false, + }) + sort?: string; + + @IsBoolean() + @Transform(({ value }) => value === 'true') + @ApiProperty({ + example: false, + description: 'Sort in ascending order if true, descending if false.', + required: false, + }) + order?: boolean; + + @IsOptional() + @IsBoolean() + @ApiProperty({ + example: true, + description: 'Search Users.', + }) + searchUsers?: boolean; + + @IsOptional() + @IsBoolean() + @ApiProperty({ + example: true, + description: 'Search Songs.', + }) + searchSongs?: boolean; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} 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..5fb2e726 --- /dev/null +++ b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts @@ -0,0 +1,65 @@ +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..4ecf5909 --- /dev/null +++ b/shared/validation/user/dto/UpdateUserProfile.dto.ts @@ -0,0 +1,160 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + 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_]+/, + 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; +} diff --git a/shared/validation/user/dto/UserPreview.dto.ts b/shared/validation/user/dto/UserPreview.dto.ts new file mode 100644 index 00000000..da3e1c87 --- /dev/null +++ b/shared/validation/user/dto/UserPreview.dto.ts @@ -0,0 +1,8 @@ +export class UserPreviewDto { + username: string; + profileImage: string; + + 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..d4654081 --- /dev/null +++ b/shared/validation/user/dto/UserProfileView.dto.ts @@ -0,0 +1,51 @@ +export class SocialLinks { + bandcamp?: string; + discord?: string; + facebook?: string; + github?: string; + instagram?: string; + reddit?: string; + snapchat?: string; + soundcloud?: string; + spotify?: string; + steam?: string; + telegram?: string; + tiktok?: string; + threads?: string; + twitch?: string; + x?: string; + youtube?: string; +} + +export class UserProfileViewDto { + username: string; + publicName: string; + profileImage: string; + description: string; + lastSeen: Date; + loginCount: number; + loginStreak: number; + playCount: number; + + socialLinks: InstanceType; + + public static fromUserDocument(user: UserProfileViewDto): 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/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; +} diff --git a/shared/validation/user/dto/UserSearchView.dto.ts b/shared/validation/user/dto/UserSearchView.dto.ts new file mode 100644 index 00000000..06c73f82 --- /dev/null +++ b/shared/validation/user/dto/UserSearchView.dto.ts @@ -0,0 +1,23 @@ +import { UserDocument } from '@server/user/entity/user.entity'; + +export class UserSearchViewDto { + id: string; + username: string; + profileImage: string; + songCount: number; + createdAt: Date; + updatedAt: Date; + + static fromUserDocument( + doc: UserDocument & { songCount: number }, + ): UserSearchViewDto { + return { + id: doc.publicName, + username: doc.publicName, + profileImage: doc.profileImage, + songCount: doc.songCount, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} diff --git a/web/package.json b/web/package.json index a7c28ab9..08a8bf7c 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.4" }, "devDependencies": { "@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0", 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/app/(content)/user/[username]/page.tsx b/web/src/app/(content)/user/[username]/page.tsx new file mode 100644 index 00000000..ad44783f --- /dev/null +++ b/web/src/app/(content)/user/[username]/page.tsx @@ -0,0 +1,34 @@ +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); + } + + if (userData) { + // set the page title to the user's name + return ; + } else { + return ; + } +}; + +export default UserPage; 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}`, }, 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/browse/components/SongCard.tsx b/web/src/modules/browse/components/SongCard.tsx index ef5d4305..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 */} @@ -48,9 +51,22 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { {!song ? ( ) : ( - `${song.uploader.username} • ${formatTimeAgo( - new Date(song.createdAt), - )}` + <> + {/* TODO: this should be a Link component, but the whole card is a link itself + and the 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))} + )}

{/* Play icon & count */} diff --git a/web/src/modules/search/components/SearchPageComponent.tsx b/web/src/modules/search/components/SearchPageComponent.tsx new file mode 100644 index 00000000..e89483e9 --- /dev/null +++ b/web/src/modules/search/components/SearchPageComponent.tsx @@ -0,0 +1,162 @@ +'use client'; + +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 { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { useSearch } from './client/context/useSearch'; + +type UserCardProps = { + user: UserSearchViewDto; +}; + +export const UserCard = ({ user }: UserCardProps) => { + const { id, profileImage, songCount, username } = user; + const router = useRouter(); + return ( +
router.push(`/user/${id}`)} + > + {/* Profile Image */} +
+ {`Profile +
+ + {/* Username */} +

+ {username} +

+ + {/* Song Count */} +

+ {songCount} {songCount === 1 ? 'song' : 'songs'} +

+ + {/* User ID (Optional) */} +

ID: {id}

+
+ ); +}; + +export const UserCardSkeleton = () => { + return ( +
+ {/* Profile Image Skeleton */} +
+
+
+ + {/* Username Skeleton */} +
+ + {/* Song Count Skeleton */} +
+ + {/* User ID Skeleton */} +
+
+ ); +}; + +export const SearchPageComponent = () => { + const searchParams = useSearchParams(); + const [currentPage, setCurrentPage] = useState(1); + + const { data, query, isLoading, limit, fetchSearchResults } = useSearch(); + + const router = useRouter(); + + useEffect(() => { + const query = searchParams.get('query') || ''; + const page = searchParams.get('page') || '1'; + const limit = searchParams.get('limit') || '20'; + + fetchSearchResults(query, parseInt(page), parseInt(limit)); + setCurrentPage(parseInt(page)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + const handlePageChange = (newPage: number) => { + const query = searchParams.get('query') || ''; + const limit = searchParams.get('limit') || '20'; + + const queryParam = new URLSearchParams({ + page: newPage.toString(), + limit: limit, + query, + }); + + router.push(`/search-user?${queryParam.toString()}`); + }; + + return ( + <> + {/* Search Header */} +
+ +
+ +

+ Search Results +

+ {query && ( +

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

+ )} + + {/* Loading State */} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : data.length === 0 ? ( +
+ No results found. Try searching for something else. +
+ ) : ( + <> + {/* User Cards */} +
+ {data.map((user: any) => ( + + ))} +
+ + {/* Pagination Controls */} +
+ + +
+ + )} + + ); +}; 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..77fa4198 --- /dev/null +++ b/web/src/modules/search/components/client/context/useSearch.tsx @@ -0,0 +1,57 @@ +'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'; + +type SearchState = { + fetchSearchResults: ( + query: string, + page: number, + limit: number, + ) => Promise; + query: string; + page: number; + limit: number; + data: UserSearchViewDto[]; + isLoading: boolean; +}; + +export const useSearch = create((set) => { + 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, + }, + }); + + const { data } = result; + + set({ + query: query, + page: data.page, + limit: data.limit, + data: data.data, + isLoading: false, + }); + }; + + return { + fetchSearchResults, + query: '', + page: 1, + limit: 20, + data: [], + 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 new file mode 100644 index 00000000..01f82a58 --- /dev/null +++ b/web/src/modules/shared/components/layout/BlockSearchProps.tsx @@ -0,0 +1,53 @@ +'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'; + +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +export const BlockSearch = () => { + const [query, setQuery] = useState(''); + const router = useRouter(); + + 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 b17996ec..16106dea 100644 --- a/web/src/modules/shared/components/layout/BlockTab.tsx +++ b/web/src/modules/shared/components/layout/BlockTab.tsx @@ -6,17 +6,21 @@ import { cn } from '@web/src/lib/tailwind.utils'; import { MusicalNote } from './MusicalNote'; +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..3df4eeeb 100644 --- a/web/src/modules/shared/components/layout/Header.tsx +++ b/web/src/modules/shared/components/layout/Header.tsx @@ -17,7 +17,7 @@ import { BlockTab } from './BlockTab'; import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; -export async function Header() { +export const Header = async () => { let isLogged; let userData; @@ -33,13 +33,19 @@ export async function Header() { } return ( -
+ ); -} +}; diff --git a/web/src/modules/song/components/SongPageButtons.tsx b/web/src/modules/song/components/SongPageButtons.tsx index e2c85d73..5d040d3d 100644 --- a/web/src/modules/song/components/SongPageButtons.tsx +++ b/web/src/modules/song/components/SongPageButtons.tsx @@ -40,17 +40,22 @@ const VisibilityBadge = () => { const UploaderBadge = ({ user }: { user: SongViewDtoType['uploader'] }) => { return (
- -
+ + + +

{user.username}

{/*

410 followers

*/} -
+
); }; 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 4ac515af..8e30ace0 100644 --- a/web/src/modules/user/components/UserProfile.tsx +++ b/web/src/modules/user/components/UserProfile.tsx @@ -1,52 +1,204 @@ +'use client'; +import { deepFreeze } from '@shared/validation/common/deepFreeze'; +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import Image from 'next/image'; +import { useEffect } from 'react'; +import { z as zod } from 'zod'; +import { create } from 'zustand'; -import { SocialLinksTypes, UserProfileData } from '../../auth/types/User'; +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: UserProfileData; + initialUserData: UserProfileViewDto; + songData: SongPreviewDto[] | null; + isSelf?: boolean; }; -const UserProfile = ({ userData }: UserProfileProps) => { - const { - lastLogin, - loginStreak, - playCount, - publicName, - description, - profileImage, - socialLinks, - } = userData; +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_]+/, + 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_-]+/, +}); + +const socialLinksSchema = zod.object({ + bandcamp: zod.string().regex(LinkRegexes.bandcamp).optional(), + discord: zod.string().regex(LinkRegexes.discord).optional(), + facebook: zod.string().regex(LinkRegexes.facebook).optional(), + github: zod.string().regex(LinkRegexes.github).optional(), + instagram: zod.string().regex(LinkRegexes.instagram).optional(), + reddit: zod.string().regex(LinkRegexes.reddit).optional(), + snapchat: zod.string().regex(LinkRegexes.snapchat).optional(), + soundcloud: zod.string().regex(LinkRegexes.soundcloud).optional(), + spotify: zod.string().regex(LinkRegexes.spotify).optional(), + steam: zod.string().regex(LinkRegexes.steam).optional(), + telegram: zod.string().regex(LinkRegexes.telegram).optional(), + tiktok: zod.string().regex(LinkRegexes.tiktok).optional(), + threads: zod.string().regex(LinkRegexes.threads).optional(), + twitch: zod.string().regex(LinkRegexes.twitch).optional(), + x: zod.string().regex(LinkRegexes.x).optional(), + youtube: zod.string().regex(LinkRegexes.youtube).optional(), +}); + +const userProfileEditFormSchema = zod.object({ + username: zod.string().min(3).max(20), + description: zod.string().optional(), + socialLinks: socialLinksSchema, +}); + +type UserProfileEditFormSchema = zod.infer; + +type UserProfileEditStore = { + isLoading: boolean; + isLocked: boolean; + userData: UserProfileViewDto | null; + setUserData: (data: UserProfileViewDto) => void; + updateUserData: (data: Partial) => void; +}; + +const useUserProfileEdit = create((set, get) => { + return { + isLoading: true, + isLocked: true, + userData: null, + setUserData: (data: UserProfileViewDto) => { + set({ userData: data }); + set({ isLoading: false }); + set({ isLocked: false }); + }, + updateUserData: (data: Partial) => { + set({ isLoading: true }); + set({ isLocked: true }); + + // TODO: do some fetch to update the user data + + // update the user data in the store + set({ + userData: { + ...get().userData, + ...data, + } as UserProfileViewDto, + }); + + set({ isLoading: false }); + set({ isLocked: false }); + }, + }; +}); + +export const UserProfile: React.FC = ({ + initialUserData, + songData, + isSelf = false, // isSelf decides if the user is viewing their own profile +}) => { + const { lastSeen, username, description, profileImage, socialLinks } = + initialUserData; + + const { setUserData, isLoading, isLocked, userData } = useUserProfileEdit(); + + + + useEffect(() => { + setUserData(initialUserData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialUserData]); return ( -
- {publicName} -

{publicName}

-

{description}

-

Last Login: {lastLogin.toLocaleString()}

-

Login Streak: {loginStreak}

-

Play Count: {playCount}

-
-
+
+ {/* HEADER */} +
+
+ {`Profile +
+ {/* Display name */} +
+

{username}

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

+ {`@${username}`} + {` • ${songData?.length || 0} songs • 2,534 plays`}{' '} + {/* Dynamic song count */} +

+ + {/* Description */} +

+ {description || 'No description available.'}{' '} + {/* Dynamic description */} +

+ + {/* Social links */} +
+ {Object.entries(socialLinks).map(([key, value], i) => ( + + ))} +
+
+ +
+ +
+ {/* Joined */} +

Joined

+

+ {new Date(lastSeen).toLocaleDateString('en-UK')} + {` (${formatTimeAgo( + new Date(lastSeen), + )})`} +

+ + {/* Last seen */} +

Last seen

+

+ {new Date(lastSeen).toLocaleDateString('en-UK')} + {` (${formatTimeAgo( + new Date(lastSeen), + )})`} +

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

Songs

+ {songData ? ( + + {songData.map((song, i) => ( + + ))} + + ) : ( +

No songs uploaded yet.

+ )} +
+
); }; - -export default UserProfile; 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 70e8dd00..38e11593 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 type { 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=${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'); + } +};