diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..920189ed --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,29 @@ +# Gemini Workspace Instructions + +This document provides instructions for the Gemini AI assistant to effectively interact with the NoteBlockWorld project. + +## Package Manager + +This project uses **Bun** as the package manager and runtime. Do not use `npm`, `yarn`, or `pnpm`. + +- **Installation:** `bun install` +- **Running scripts:** `bun run ` +- **Adding dependencies:** `bun add ` +- **Running tests:** `bun test` + +## Project Structure + +This is a TypeScript monorepo managed with Bun workspaces. + +- **`apps/`**: Contains the main applications. + - **`apps/backend`**: A NestJS application for the server-side logic. + - **`apps/frontend`**: A Next.js application for the user interface. +- **`packages/`**: Contains shared libraries and modules used across the monorepo. + - **`packages/api-client`**: Client for communicating with the backend API. + - **`packages/configs`**: Shared configurations (e.g., ESLint, Prettier). + - **`packages/database`**: Database schemas, queries, and connection logic. + - **`packages/song`**: Core logic for handling and manipulating song data. + - **`packages/sounds`**: Logic related to fetching and managing sounds. + - **`packages/thumbnail`**: A library for generating song thumbnails. +- **`tests/`**: Contains end-to-end tests, likely using Cypress. +- **`tsconfig.base.json`**: The base TypeScript configuration for the entire monorepo. diff --git a/NoteBlockWorld.code-workspace b/NoteBlockWorld.code-workspace index 0368f60e..b8a0745d 100644 --- a/NoteBlockWorld.code-workspace +++ b/NoteBlockWorld.code-workspace @@ -1,9 +1,5 @@ { "folders": [ - { - "path": ".", - "name": "Root" - }, { "path": "./apps/backend", "name": "Backend" @@ -32,6 +28,10 @@ "path": "./packages/thumbnail", "name": "thumbnail" }, + { + "path": ".", + "name": "Root" + }, ], "settings": { "window.title": "${dirty}${rootName}${separator}${profileName}${separator}${appName}", diff --git a/apps/backend/package.json b/apps/backend/package.json index f3861fb6..0fcad4ad 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -14,7 +14,7 @@ "start:dev": "bun --watch run src/main.ts", "start:debug": "bun --watch run src/main.ts", "start:prod": "node dist/main", - "lint": "eslint \"src/**/*.ts\" --fix", + "lint": "eslint \"src/**/*.ts\" --fix --config ../../eslint.config.ts", "test": "bun test src/**/*.spec.ts", "test:watch": "bun test src/**/*.spec.ts --watch", "test:cov": "bun test src/**/*.spec.ts --coverage", @@ -22,6 +22,7 @@ "test:e2e": "bun test e2e/**/*.spec.ts" }, "dependencies": { + "@types/bun": "^1.2.10", "@aws-sdk/client-s3": "3.717.0", "@aws-sdk/s3-request-presigner": "3.717.0", "@encode42/nbs.js": "^5.0.2", @@ -67,6 +68,7 @@ "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", + "@stylistic/eslint-plugin": "^5.4.0", "@types/bcryptjs": "^2.4.6", "@types/bun": "^1.2.10", "@types/express": "^4.17.21", diff --git a/apps/backend/scripts/build.ts b/apps/backend/scripts/build.ts index 5417739e..4d261849 100644 --- a/apps/backend/scripts/build.ts +++ b/apps/backend/scripts/build.ts @@ -61,11 +61,11 @@ const build = async () => { const result = await Bun.build({ entrypoints: ['./src/main.ts'], - outdir: './dist', - target: 'bun', - minify: false, - sourcemap: 'linked', - external: optionalRequirePackages.filter((pkg) => { + outdir : './dist', + target : 'bun', + minify : false, + sourcemap : 'linked', + external : optionalRequirePackages.filter((pkg) => { try { require(pkg); return false; diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b3f2e3bd..40f44a37 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -14,20 +14,19 @@ import { ParseTokenPipe } from './lib/parseToken'; import { MailingModule } from './mailing/mailing.module'; import { SeedModule } from './seed/seed.module'; import { SongModule } from './song/song.module'; -import { SongBrowserModule } from './song-browser/song-browser.module'; import { UserModule } from './user/user.module'; @Module({ imports: [ ConfigModule.forRoot({ - isGlobal: true, + isGlobal : true, envFilePath: ['.env.development', '.env.production'], validate, }), //DatabaseModule, MongooseModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], + imports : [ConfigModule], + inject : [ConfigService], useFactory: ( configService: ConfigService, ): MongooseModuleFactoryOptions => { @@ -35,15 +34,15 @@ import { UserModule } from './user/user.module'; Logger.debug(`Connecting to ${url}`); return { - uri: url, + uri : url, retryAttempts: 10, - retryDelay: 3000, + retryDelay : 3000, }; }, }), // Mailing MailerModule.forRootAsync({ - imports: [ConfigModule], + imports : [ConfigModule], useFactory: (configService: ConfigService) => { const transport = configService.getOrThrow('MAIL_TRANSPORT'); const from = configService.getOrThrow('MAIL_FROM'); @@ -51,11 +50,11 @@ import { UserModule } from './user/user.module'; AppModule.logger.debug(`MAIL_FROM: ${from}`); return { transport: transport, - defaults: { + defaults : { from: from, }, template: { - dir: __dirname + '/mailing/templates', + dir : __dirname + '/mailing/templates', adapter: new HandlebarsAdapter(), options: { strict: true, @@ -68,7 +67,7 @@ import { UserModule } from './user/user.module'; // Throttler ThrottlerModule.forRoot([ { - ttl: 60, + ttl : 60, limit: 256, // 256 requests per minute }, ]), @@ -76,16 +75,15 @@ import { UserModule } from './user/user.module'; UserModule, AuthModule.forRootAsync(), FileModule.forRootAsync(), - SongBrowserModule, SeedModule.forRoot(), EmailLoginModule, MailingModule, ], controllers: [], - providers: [ + providers : [ ParseTokenPipe, { - provide: APP_GUARD, + provide : APP_GUARD, useClass: ThrottlerGuard, }, ], diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index d6bae4fa..7420acac 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -6,10 +6,10 @@ import { AuthService } from './auth.service'; import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; const mockAuthService = { - githubLogin: jest.fn(), - googleLogin: jest.fn(), - discordLogin: jest.fn(), - verifyToken: jest.fn(), + githubLogin : jest.fn(), + googleLogin : jest.fn(), + discordLogin : jest.fn(), + verifyToken : jest.fn(), loginWithEmail: jest.fn(), }; @@ -24,13 +24,13 @@ describe('AuthController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], - providers: [ + providers : [ { - provide: AuthService, + provide : AuthService, useValue: mockAuthService, }, { - provide: MagicLinkEmailStrategy, + provide : MagicLinkEmailStrategy, useValue: mockMagicLinkEmailStrategy, }, ], diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 66e29486..482c8423 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -32,7 +32,7 @@ export class AuthController { @Throttle({ default: { // one every 1 hour - ttl: 60 * 60 * 1000, + ttl : 60 * 60 * 1000, limit: 1, }, }) @@ -44,11 +44,11 @@ export class AuthController { content: { 'application/json': { schema: { - type: 'object', + type : 'object', properties: { destination: { - type: 'string', - example: 'vycasnicolas@gmail.com', + type : 'string', + example : 'vycasnicolas@gmail.com', description: 'Email address to send the magic link to', }, }, @@ -58,7 +58,7 @@ export class AuthController { }, }, }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async magicLinkLogin(@Req() req: Request, @Res() res: Response) { throw new HttpException('Not implemented', HttpStatus.NOT_IMPLEMENTED); // TODO: uncomment this line to enable magic link login diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index e266b0bf..c6cfa556 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -17,14 +17,14 @@ import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; export class AuthModule { static forRootAsync(): DynamicModule { return { - module: AuthModule, + module : AuthModule, imports: [ UserModule, ConfigModule.forRoot(), MailingModule, JwtModule.registerAsync({ - inject: [ConfigService], - imports: [ConfigModule], + inject : [ConfigService], + imports : [ConfigModule], useFactory: async (config: ConfigService) => { const JWT_SECRET = config.get('JWT_SECRET'); const JWT_EXPIRES_IN = config.get('JWT_EXPIRES_IN'); @@ -39,14 +39,14 @@ export class AuthModule { } return { - secret: JWT_SECRET, + secret : JWT_SECRET, signOptions: { expiresIn: JWT_EXPIRES_IN || '60s' }, }; }, }), ], controllers: [AuthController], - providers: [ + providers : [ AuthService, ConfigService, GoogleStrategy, @@ -55,56 +55,56 @@ export class AuthModule { MagicLinkEmailStrategy, JwtStrategy, { - inject: [ConfigService], - provide: 'COOKIE_EXPIRES_IN', + inject : [ConfigService], + provide : 'COOKIE_EXPIRES_IN', useFactory: (configService: ConfigService) => configService.getOrThrow('COOKIE_EXPIRES_IN'), }, { - inject: [ConfigService], - provide: 'SERVER_URL', + inject : [ConfigService], + provide : 'SERVER_URL', useFactory: (configService: ConfigService) => configService.getOrThrow('SERVER_URL'), }, { - inject: [ConfigService], - provide: 'FRONTEND_URL', + inject : [ConfigService], + provide : 'FRONTEND_URL', useFactory: (configService: ConfigService) => configService.getOrThrow('FRONTEND_URL'), }, { - inject: [ConfigService], - provide: 'JWT_SECRET', + inject : [ConfigService], + provide : 'JWT_SECRET', useFactory: (configService: ConfigService) => configService.getOrThrow('JWT_SECRET'), }, { - inject: [ConfigService], - provide: 'JWT_EXPIRES_IN', + inject : [ConfigService], + provide : 'JWT_EXPIRES_IN', useFactory: (configService: ConfigService) => configService.getOrThrow('JWT_EXPIRES_IN'), }, { - inject: [ConfigService], - provide: 'JWT_REFRESH_SECRET', + inject : [ConfigService], + provide : 'JWT_REFRESH_SECRET', useFactory: (configService: ConfigService) => configService.getOrThrow('JWT_REFRESH_SECRET'), }, { - inject: [ConfigService], - provide: 'JWT_REFRESH_EXPIRES_IN', + inject : [ConfigService], + provide : 'JWT_REFRESH_EXPIRES_IN', useFactory: (configService: ConfigService) => configService.getOrThrow('JWT_REFRESH_EXPIRES_IN'), }, { - inject: [ConfigService], - provide: 'MAGIC_LINK_SECRET', + inject : [ConfigService], + provide : 'MAGIC_LINK_SECRET', useFactory: (configService: ConfigService) => configService.getOrThrow('MAGIC_LINK_SECRET'), }, { - inject: [ConfigService], - provide: 'APP_DOMAIN', + inject : [ConfigService], + provide : 'APP_DOMAIN', useFactory: (configService: ConfigService) => configService.get('APP_DOMAIN'), }, diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 311fece9..afcd563f 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -1,7 +1,8 @@ +import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; + import type { UserDocument } from '@nbw/database'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; import type { Request, Response } from 'express'; import { UserService } from '@server/user/user.service'; @@ -10,9 +11,9 @@ import { AuthService } from './auth.service'; import { Profile } from './types/profile'; const mockAxios = { - get: jest.fn(), - post: jest.fn(), - put: jest.fn(), + get : jest.fn(), + post : jest.fn(), + put : jest.fn(), delete: jest.fn(), create: jest.fn(), }; @@ -21,15 +22,15 @@ mock.module('axios', () => mockAxios); const mockUserService = { generateUsername: jest.fn(), - findByEmail: jest.fn(), - findByID: jest.fn(), - create: jest.fn(), + findByEmail : jest.fn(), + findByID : jest.fn(), + create : jest.fn(), }; const mockJwtService = { - decode: jest.fn(), + decode : jest.fn(), signAsync: jest.fn(), - verify: jest.fn(), + verify : jest.fn(), }; describe('AuthService', () => { @@ -42,47 +43,47 @@ describe('AuthService', () => { providers: [ AuthService, { - provide: UserService, + provide : UserService, useValue: mockUserService, }, { - provide: JwtService, + provide : JwtService, useValue: mockJwtService, }, { - provide: 'COOKIE_EXPIRES_IN', + provide : 'COOKIE_EXPIRES_IN', useValue: '3600', }, { - provide: 'FRONTEND_URL', + provide : 'FRONTEND_URL', useValue: 'http://frontend.test.com', }, { - provide: 'COOKIE_EXPIRES_IN', + provide : 'COOKIE_EXPIRES_IN', useValue: '3600', }, { - provide: 'JWT_SECRET', + provide : 'JWT_SECRET', useValue: 'test-jwt-secret', }, { - provide: 'JWT_EXPIRES_IN', + provide : 'JWT_EXPIRES_IN', useValue: '1d', }, { - provide: 'JWT_REFRESH_SECRET', + provide : 'JWT_REFRESH_SECRET', useValue: 'test-jwt-refresh-secret', }, { - provide: 'JWT_REFRESH_EXPIRES_IN', + provide : 'JWT_REFRESH_EXPIRES_IN', useValue: '7d', }, { - provide: 'WHITELISTED_USERS', + provide : 'WHITELISTED_USERS', useValue: 'tomast1337,bentroen,testuser', }, { - provide: 'APP_DOMAIN', + provide : 'APP_DOMAIN', useValue: '.test.com', }, ], @@ -103,7 +104,7 @@ describe('AuthService', () => { const res = { status: jest.fn().mockReturnThis(), - json: jest.fn(), + json : jest.fn(), } as any; await authService.verifyToken(req, res); @@ -120,7 +121,7 @@ describe('AuthService', () => { const res = { status: jest.fn().mockReturnThis(), - json: jest.fn(), + json : jest.fn(), } as any; await authService.verifyToken(req, res); @@ -136,7 +137,7 @@ describe('AuthService', () => { const res = { status: jest.fn().mockReturnThis(), - json: jest.fn(), + json : jest.fn(), } as any; mockJwtService.verify.mockReturnValueOnce({ id: 'test-id' }); @@ -155,7 +156,7 @@ describe('AuthService', () => { const res = { status: jest.fn().mockReturnThis(), - json: jest.fn(), + json : jest.fn(), } as any; const decodedToken = { id: 'test-id' }; @@ -206,17 +207,17 @@ describe('AuthService', () => { const tokens = await (authService as any).createJwtPayload(payload); expect(tokens).toEqual({ - access_token: accessToken, + access_token : accessToken, refresh_token: refreshToken, }); expect(jwtService.signAsync).toHaveBeenCalledWith(payload, { - secret: 'test-jwt-secret', + secret : 'test-jwt-secret', expiresIn: '1d', }); expect(jwtService.signAsync).toHaveBeenCalledWith(payload, { - secret: 'test-jwt-refresh-secret', + secret : 'test-jwt-refresh-secret', expiresIn: '7d', }); }); @@ -225,18 +226,18 @@ describe('AuthService', () => { describe('GenTokenRedirect', () => { it('should set cookies and redirect to the frontend URL', async () => { const user_registered = { - _id: 'user-id', - email: 'test@example.com', + _id : 'user-id', + email : 'test@example.com', username: 'testuser', } as unknown as UserDocument; const res = { - cookie: jest.fn(), + cookie : jest.fn(), redirect: jest.fn(), } as unknown as Response; const tokens = { - access_token: 'access-token', + access_token : 'access-token', refresh_token: 'refresh-token', }; @@ -245,8 +246,8 @@ describe('AuthService', () => { await (authService as any).GenTokenRedirect(user_registered, res); expect((authService as any).createJwtPayload).toHaveBeenCalledWith({ - id: 'user-id', - email: 'test@example.com', + id : 'user-id', + email : 'test@example.com', username: 'testuser', }); @@ -271,8 +272,8 @@ describe('AuthService', () => { describe('verifyAndGetUser', () => { it('should create a new user if the user is not registered', async () => { const user: Profile = { - username: 'testuser', - email: 'test@example.com', + username : 'testuser', + email : 'test@example.com', profileImage: 'http://example.com/photo.jpg', }; @@ -285,7 +286,7 @@ describe('AuthService', () => { expect(userService.create).toHaveBeenCalledWith( expect.objectContaining({ - email: 'test@example.com', + email : 'test@example.com', profileImage: 'http://example.com/photo.jpg', }), ); @@ -295,13 +296,13 @@ describe('AuthService', () => { it('should return the registered user if the user is already registered', async () => { const user: Profile = { - username: 'testuser', - email: 'test@example.com', + username : 'testuser', + email : 'test@example.com', profileImage: 'http://example.com/photo.jpg', }; const registeredUser = { - id: 'registered-user-id', + id : 'registered-user-id', profileImage: 'http://example.com/photo.jpg', }; @@ -315,15 +316,15 @@ describe('AuthService', () => { it('should update the profile image if it has changed', async () => { const user: Profile = { - username: 'testuser', - email: 'test@example.com', + username : 'testuser', + email : 'test@example.com', profileImage: 'http://example.com/new-photo.jpg', }; const registeredUser = { - id: 'registered-user-id', + id : 'registered-user-id', profileImage: 'http://example.com/old-photo.jpg', - save: jest.fn(), + save : jest.fn(), }; mockUserService.findByEmail.mockResolvedValue(registeredUser); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index b590e003..4332a6cf 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -7,6 +7,8 @@ import type { Request, Response } from 'express'; import { UserService } from '@server/user/user.service'; + + import { DiscordUser } from './types/discordProfile'; import { GithubAccessToken, GithubEmailList } from './types/githubProfile'; import { GoogleProfile } from './types/googleProfile'; @@ -76,8 +78,8 @@ export class AuthService { const profile = { // Generate username from display name - username: email.split('@')[0], - email: email, + username : email.split('@')[0], + email : email, profileImage: user.photos[0].value, }; @@ -93,8 +95,8 @@ export class AuthService { const newUsername = await this.userService.generateUsername(baseUsername); const newUser = new CreateUser({ - username: newUsername, - email: email, + username : newUsername, + email : email, profileImage: profileImage, }); @@ -134,8 +136,8 @@ export class AuthService { const email = response.data.filter((email) => email.primary)[0].email; const user_registered = await this.verifyAndGetUser({ - username: profile.username, - email: email, + username : profile.username, + email : email, profileImage: profile.photos[0].value, }); @@ -148,8 +150,8 @@ export class AuthService { const profile = { // Generate username from display name - username: user.username, - email: user.email, + username : user.username, + email : user.email, profileImage: profilePictureUrl, }; @@ -172,17 +174,17 @@ export class AuthService { public async createJwtPayload(payload: TokenPayload): Promise { const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(payload, { - secret: this.JWT_SECRET, + secret : this.JWT_SECRET, expiresIn: this.JWT_EXPIRES_IN, }), this.jwtService.signAsync(payload, { - secret: this.JWT_REFRESH_SECRET, + secret : this.JWT_REFRESH_SECRET, expiresIn: this.JWT_REFRESH_EXPIRES_IN, }), ]); return { - access_token: accessToken, + access_token : accessToken, refresh_token: refreshToken, }; } @@ -192,8 +194,8 @@ export class AuthService { res: Response>, ): Promise { const token = await this.createJwtPayload({ - id: user_registered._id.toString(), - email: user_registered.email, + id : user_registered._id.toString(), + email : user_registered.email, username: user_registered.username, }); diff --git a/apps/backend/src/auth/strategies/JWT.strategy.spec.ts b/apps/backend/src/auth/strategies/JWT.strategy.spec.ts index 052cae9e..b8d92640 100644 --- a/apps/backend/src/auth/strategies/JWT.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/JWT.strategy.spec.ts @@ -13,7 +13,7 @@ describe('JwtStrategy', () => { providers: [ JwtStrategy, { - provide: ConfigService, + provide : ConfigService, useValue: { getOrThrow: jest.fn().mockReturnValue('test-secret'), }, diff --git a/apps/backend/src/auth/strategies/JWT.strategy.ts b/apps/backend/src/auth/strategies/JWT.strategy.ts index 6311d4e4..f0498f57 100644 --- a/apps/backend/src/auth/strategies/JWT.strategy.ts +++ b/apps/backend/src/auth/strategies/JWT.strategy.ts @@ -11,8 +11,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { const JWT_SECRET = config.getOrThrow('JWT_SECRET'); super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: JWT_SECRET, + jwtFromRequest : ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey : JWT_SECRET, passReqToCallback: true, }); } diff --git a/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts b/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts index e075f778..7490f0a0 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts @@ -10,10 +10,10 @@ describe('DiscordStrategy', () => { beforeEach(() => { const config: DiscordStrategyConfig = { - clientID: 'test-client-id', + clientID : 'test-client-id', clientSecret: 'test-client-secret', - callbackUrl: 'http://localhost:3000/callback', - scope: [ + callbackUrl : 'http://localhost:3000/callback', + scope : [ DiscordPermissionScope.Email, DiscordPermissionScope.Identify, DiscordPermissionScope.Connections, @@ -35,11 +35,11 @@ describe('DiscordStrategy', () => { it('should validate config', async () => { const config: DiscordStrategyConfig = { - clientID: 'test-client-id', + clientID : 'test-client-id', clientSecret: 'test-client-secret', - callbackUrl: 'http://localhost:3000/callback', - scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], - prompt: 'consent', + callbackUrl : 'http://localhost:3000/callback', + scope : [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], + prompt : 'consent', }; await expect(strategy['validateConfig'](config)).resolves.toBeUndefined(); @@ -71,47 +71,47 @@ describe('DiscordStrategy', () => { it('should build profile', () => { const profileData = { - id: '123', - username: 'testuser', - displayName: 'Test User', - avatar: 'avatar.png', - banner: 'banner.png', - email: 'test@example.com', - verified: true, - mfa_enabled: true, + id : '123', + username : 'testuser', + displayName : 'Test User', + avatar : 'avatar.png', + banner : 'banner.png', + email : 'test@example.com', + verified : true, + mfa_enabled : true, public_flags: 1, - flags: 1, - locale: 'en-US', - global_name: 'testuser#1234', + flags : 1, + locale : 'en-US', + global_name : 'testuser#1234', premium_type: 1, - connections: [], - guilds: [], + connections : [], + guilds : [], } as unknown as Profile; const profile = strategy['buildProfile'](profileData, 'test-access-token'); expect(profile).toMatchObject({ - provider: 'discord', - id: '123', - username: 'testuser', - displayName: 'Test User', - avatar: 'avatar.png', - banner: 'banner.png', - email: 'test@example.com', - verified: true, - mfa_enabled: true, + provider : 'discord', + id : '123', + username : 'testuser', + displayName : 'Test User', + avatar : 'avatar.png', + banner : 'banner.png', + email : 'test@example.com', + verified : true, + mfa_enabled : true, public_flags: 1, - flags: 1, - locale: 'en-US', - global_name: 'testuser#1234', + flags : 1, + locale : 'en-US', + global_name : 'testuser#1234', premium_type: 1, - connections: [], - guilds: [], + connections : [], + guilds : [], access_token: 'test-access-token', - fetchedAt: expect.any(Date), - createdAt: expect.any(Date), - _raw: JSON.stringify(profileData), - _json: profileData, + fetchedAt : expect.any(Date), + createdAt : expect.any(Date), + _raw : JSON.stringify(profileData), + _json : profileData, }); }); @@ -141,9 +141,9 @@ describe('DiscordStrategy', () => { it('should enrich profile with scopes', async () => { const profile = { - id: '123', + id : '123', connections: [], - guilds: [], + guilds : [], } as unknown as Profile; const mockFetchScopeData = jest diff --git a/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts b/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts index ebc6d905..4f4574e6 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts @@ -38,10 +38,10 @@ export default class Strategy extends OAuth2Strategy { public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) { super( { - scopeSeparator: ' ', + scopeSeparator : ' ', ...options, authorizationURL: 'https://discord.com/api/oauth2/authorize', - tokenURL: 'https://discord.com/api/oauth2/token', + tokenURL : 'https://discord.com/api/oauth2/token', } as OAuth2StrategyOptions, verify, ); @@ -148,27 +148,27 @@ export default class Strategy extends OAuth2Strategy { private buildProfile(data: Profile, accessToken: string): Profile { const { id } = data; return { - provider: 'discord', - id: id, - username: data.username, - displayName: data.displayName, - avatar: data.avatar, - banner: data.banner, - email: data.email, - verified: data.verified, - mfa_enabled: data.mfa_enabled, + provider : 'discord', + id : id, + username : data.username, + displayName : data.displayName, + avatar : data.avatar, + banner : data.banner, + email : data.email, + verified : data.verified, + mfa_enabled : data.mfa_enabled, public_flags: data.public_flags, - flags: data.flags, - locale: data.locale, - global_name: data.global_name, + flags : data.flags, + locale : data.locale, + global_name : data.global_name, premium_type: data.premium_type, - connections: data.connections, - guilds: data.guilds, + connections : data.connections, + guilds : data.guilds, access_token: accessToken, - fetchedAt: new Date(), - createdAt: this.calculateCreationDate(id), - _raw: JSON.stringify(data), - _json: data as unknown as Record, + fetchedAt : new Date(), + createdAt : this.calculateCreationDate(id), + _raw : JSON.stringify(data), + _json : data as unknown as Record, }; } diff --git a/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts b/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts index 0dbc8608..ddeb10dc 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts @@ -12,7 +12,7 @@ describe('DiscordStrategy', () => { providers: [ DiscordStrategy, { - provide: ConfigService, + provide : ConfigService, useValue: { getOrThrow: jest.fn((key: string) => { switch (key) { diff --git a/apps/backend/src/auth/strategies/discord.strategy/index.ts b/apps/backend/src/auth/strategies/discord.strategy/index.ts index 61dc578a..bfdedd04 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/index.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/index.ts @@ -22,12 +22,12 @@ export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { const SERVER_URL = configService.getOrThrow('SERVER_URL'); const config = { - clientID: DISCORD_CLIENT_ID, + clientID : DISCORD_CLIENT_ID, clientSecret: DISCORD_CLIENT_SECRET, - callbackUrl: `${SERVER_URL}/api/v1/auth/discord/callback`, - scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], - fetchScope: true, - prompt: 'none', + callbackUrl : `${SERVER_URL}/api/v1/auth/discord/callback`, + scope : [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], + fetchScope : true, + prompt : 'none', }; super(config); diff --git a/apps/backend/src/auth/strategies/github.strategy.spec.ts b/apps/backend/src/auth/strategies/github.strategy.spec.ts index c8793e00..db09ecab 100644 --- a/apps/backend/src/auth/strategies/github.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/github.strategy.spec.ts @@ -12,7 +12,7 @@ describe('GithubStrategy', () => { providers: [ GithubStrategy, { - provide: ConfigService, + provide : ConfigService, useValue: { getOrThrow: jest.fn((key: string) => { switch (key) { diff --git a/apps/backend/src/auth/strategies/github.strategy.ts b/apps/backend/src/auth/strategies/github.strategy.ts index 27293151..d0740048 100644 --- a/apps/backend/src/auth/strategies/github.strategy.ts +++ b/apps/backend/src/auth/strategies/github.strategy.ts @@ -20,11 +20,11 @@ export class GithubStrategy extends PassportStrategy(strategy, 'github') { const SERVER_URL = configService.getOrThrow('SERVER_URL'); super({ - clientID: GITHUB_CLIENT_ID, + clientID : GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET, redirect_uri: `${SERVER_URL}/api/v1/auth/github/callback`, - scope: 'user:read,user:email', - state: false, + scope : 'user:read,user:email', + state : false, }); } diff --git a/apps/backend/src/auth/strategies/google.strategy.spec.ts b/apps/backend/src/auth/strategies/google.strategy.spec.ts index c1f1233e..8adf94be 100644 --- a/apps/backend/src/auth/strategies/google.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/google.strategy.spec.ts @@ -13,7 +13,7 @@ describe('GoogleStrategy', () => { providers: [ GoogleStrategy, { - provide: ConfigService, + provide : ConfigService, useValue: { getOrThrow: jest.fn((key: string) => { switch (key) { diff --git a/apps/backend/src/auth/strategies/google.strategy.ts b/apps/backend/src/auth/strategies/google.strategy.ts index a19e1789..859b1851 100644 --- a/apps/backend/src/auth/strategies/google.strategy.ts +++ b/apps/backend/src/auth/strategies/google.strategy.ts @@ -23,10 +23,10 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { GoogleStrategy.logger.debug(`Google Login callbackURL ${callbackURL}`); super({ - clientID: GOOGLE_CLIENT_ID, + clientID : GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET, - callbackURL: callbackURL, - scope: ['email', 'profile'], + callbackURL : callbackURL, + scope : ['email', 'profile'], }); } diff --git a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts index a77f26c6..cb5a2454 100644 --- a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts @@ -13,7 +13,7 @@ describe('MagicLinkEmailStrategy', () => { let _configService: ConfigService; const mockUserService = { - findByEmail: jest.fn(), + findByEmail : jest.fn(), createWithEmail: jest.fn(), }; @@ -37,11 +37,11 @@ describe('MagicLinkEmailStrategy', () => { { provide: MailingService, useValue: mockMailingService }, { provide: ConfigService, useValue: mockConfigService }, { - provide: 'MAGIC_LINK_SECRET', + provide : 'MAGIC_LINK_SECRET', useValue: 'test_secret', }, { - provide: 'SERVER_URL', + provide : 'SERVER_URL', useValue: 'http://localhost:3000', }, ], @@ -77,13 +77,13 @@ describe('MagicLinkEmailStrategy', () => { expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); expect(mockMailingService.sendEmail).toHaveBeenCalledWith({ - to: email, + to : email, context: { magicLink: 'http://localhost/api/v1/auth/magic-link/callback?token=test_token', username: 'testuser', }, - subject: 'Noteblock Magic Link', + subject : 'Noteblock Magic Link', template: 'magic-link', }); }); @@ -108,13 +108,13 @@ describe('MagicLinkEmailStrategy', () => { expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); expect(mockMailingService.sendEmail).toHaveBeenCalledWith({ - to: email, + to : email, context: { magicLink: 'http://localhost/api/v1/auth/magic-link/callback?token=test_token', username: 'testuser', }, - subject: 'Welcome to Noteblock.world', + subject : 'Welcome to Noteblock.world', template: 'magic-link-new-account', }); }); @@ -138,14 +138,14 @@ describe('MagicLinkEmailStrategy', () => { mockUserService.findByEmail.mockResolvedValue(null); mockUserService.createWithEmail.mockResolvedValue({ - email: 'test@example.com', + email : 'test@example.com', username: 'test', }); const result = await strategy.validate(payload); expect(result).toEqual({ - email: 'test@example.com', + email : 'test@example.com', username: 'test', }); }); diff --git a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts index eb528158..f819170b 100644 --- a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts +++ b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts @@ -29,9 +29,9 @@ export class MagicLinkEmailStrategy extends PassportStrategy( private readonly mailingService: MailingService, ) { super({ - secret: MAGIC_LINK_SECRET, - confirmUrl: `${SERVER_URL}/api/v1/auth/magic-link/confirm`, - callbackUrl: `${SERVER_URL}/api/v1/auth/magic-link/callback`, + secret : MAGIC_LINK_SECRET, + confirmUrl : `${SERVER_URL}/api/v1/auth/magic-link/confirm`, + callbackUrl : `${SERVER_URL}/api/v1/auth/magic-link/callback`, sendMagicLink: MagicLinkEmailStrategy.sendMagicLink( SERVER_URL, userService, @@ -57,22 +57,22 @@ export class MagicLinkEmailStrategy extends PassportStrategy( if (!user) { mailingService.sendEmail({ - to: email, + to : email, context: { magicLink: magicLink, - username: email.split('@')[0], + username : email.split('@')[0], }, - subject: 'Welcome to Noteblock.world', + subject : 'Welcome to Noteblock.world', template: 'magic-link-new-account', }); } else { mailingService.sendEmail({ - to: email, + to : email, context: { magicLink: magicLink, - username: user.username, + username : user.username, }, - subject: 'Noteblock Magic Link', + subject : 'Noteblock Magic Link', template: 'magic-link', }); } diff --git a/apps/backend/src/email-login/email-login.controller.spec.ts b/apps/backend/src/email-login/email-login.controller.spec.ts index 1e8e48a3..c352b8c9 100644 --- a/apps/backend/src/email-login/email-login.controller.spec.ts +++ b/apps/backend/src/email-login/email-login.controller.spec.ts @@ -9,7 +9,7 @@ describe('EmailLoginController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [EmailLoginController], - providers: [EmailLoginService], + providers : [EmailLoginService], }).compile(); controller = module.get(EmailLoginController); diff --git a/apps/backend/src/email-login/email-login.module.ts b/apps/backend/src/email-login/email-login.module.ts index 47414fa8..a1de52d7 100644 --- a/apps/backend/src/email-login/email-login.module.ts +++ b/apps/backend/src/email-login/email-login.module.ts @@ -5,6 +5,6 @@ import { EmailLoginService } from './email-login.service'; @Module({ controllers: [EmailLoginController], - providers: [EmailLoginService], + providers : [EmailLoginService], }) export class EmailLoginModule {} diff --git a/apps/backend/src/file/file.module.ts b/apps/backend/src/file/file.module.ts index 33303b61..17365459 100644 --- a/apps/backend/src/file/file.module.ts +++ b/apps/backend/src/file/file.module.ts @@ -7,42 +7,42 @@ import { FileService } from './file.service'; export class FileModule { static forRootAsync(): DynamicModule { return { - module: FileModule, - imports: [ConfigModule.forRoot()], + module : FileModule, + imports : [ConfigModule.forRoot()], providers: [ { - provide: 'S3_BUCKET_SONGS', + provide : 'S3_BUCKET_SONGS', useFactory: (configService: ConfigService) => configService.getOrThrow('S3_BUCKET_SONGS'), inject: [ConfigService], }, { - provide: 'S3_BUCKET_THUMBS', + provide : 'S3_BUCKET_THUMBS', useFactory: (configService: ConfigService) => configService.getOrThrow('S3_BUCKET_THUMBS'), inject: [ConfigService], }, { - provide: 'S3_KEY', + provide : 'S3_KEY', useFactory: (configService: ConfigService) => configService.getOrThrow('S3_KEY'), inject: [ConfigService], }, { - provide: 'S3_SECRET', + provide : 'S3_SECRET', useFactory: (configService: ConfigService) => configService.getOrThrow('S3_SECRET'), inject: [ConfigService], }, { - provide: 'S3_ENDPOINT', + provide : 'S3_ENDPOINT', useFactory: (configService: ConfigService) => configService.getOrThrow('S3_ENDPOINT'), inject: [ConfigService], }, { - provide: 'S3_REGION', + provide : 'S3_REGION', useFactory: (configService: ConfigService) => configService.getOrThrow('S3_REGION'), inject: [ConfigService], diff --git a/apps/backend/src/file/file.service.spec.ts b/apps/backend/src/file/file.service.spec.ts index 8d5e9e64..5e7642f8 100644 --- a/apps/backend/src/file/file.service.spec.ts +++ b/apps/backend/src/file/file.service.spec.ts @@ -1,7 +1,8 @@ +import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; + import { S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; import { FileService } from './file.service'; @@ -11,12 +12,12 @@ mock.module('@aws-sdk/client-s3', () => { }; return { - S3Client: jest.fn(() => mS3Client), - GetObjectCommand: jest.fn(), - PutObjectCommand: jest.fn(), + S3Client : jest.fn(() => mS3Client), + GetObjectCommand : jest.fn(), + PutObjectCommand : jest.fn(), HeadBucketCommand: jest.fn(), - ObjectCannedACL: { - private: 'private', + ObjectCannedACL : { + private : 'private', public_read: 'public-read', }, }; @@ -35,27 +36,27 @@ describe('FileService', () => { providers: [ FileService, { - provide: 'S3_BUCKET_THUMBS', + provide : 'S3_BUCKET_THUMBS', useValue: 'test-bucket-thumbs', }, { - provide: 'S3_BUCKET_SONGS', + provide : 'S3_BUCKET_SONGS', useValue: 'test-bucket-songs', }, { - provide: 'S3_KEY', + provide : 'S3_KEY', useValue: 'test-key', }, { - provide: 'S3_SECRET', + provide : 'S3_SECRET', useValue: 'test-secret', }, { - provide: 'S3_ENDPOINT', + provide : 'S3_ENDPOINT', useValue: 'test-endpoint', }, { - provide: 'S3_REGION', + provide : 'S3_REGION', useValue: 'test-region', }, ], diff --git a/apps/backend/src/file/file.service.ts b/apps/backend/src/file/file.service.ts index 1eda743e..f9160149 100644 --- a/apps/backend/src/file/file.service.ts +++ b/apps/backend/src/file/file.service.ts @@ -74,10 +74,10 @@ export class FileService { // Create S3 client const s3Client = new S3Client({ - region: region, - endpoint: endpoint, + region : region, + endpoint : endpoint, credentials: { - accessKeyId: key, + accessKeyId : key, secretAccessKey: secret, }, forcePathStyle: endpoint.includes('localhost') ? true : false, @@ -129,8 +129,8 @@ export class FileService { const bucket = this.S3_BUCKET_SONGS; const command = new GetObjectCommand({ - Bucket: bucket, - Key: key, + Bucket : bucket, + Key : key, ResponseContentDisposition: `attachment; filename="${filename.replace( /[/"]/g, '_', @@ -183,7 +183,7 @@ export class FileService { const command = new GetObjectCommand({ Bucket: bucket, - Key: nbsFileUrl, + Key : nbsFileUrl, }); try { @@ -204,12 +204,12 @@ export class FileService { accessControl: ObjectCannedACL = ObjectCannedACL.public_read, ) { const params = { - Bucket: bucket, - Key: String(name), - Body: file, - ACL: accessControl, - ContentType: mimetype, - ContentDisposition: `attachment; filename=${name.split('/').pop()}`, + Bucket : bucket, + Key : String(name), + Body : file, + ACL : accessControl, + ContentType : mimetype, + ContentDisposition : `attachment; filename=${name.split('/').pop()}`, CreateBucketConfiguration: { LocationConstraint: 'ap-south-1', }, @@ -231,7 +231,7 @@ export class FileService { const command = new GetObjectCommand({ Bucket: bucket, - Key: nbsFileUrl, + Key : nbsFileUrl, }); try { diff --git a/apps/backend/src/lib/GetRequestUser.spec.ts b/apps/backend/src/lib/GetRequestUser.spec.ts index ebc2e65f..35c27c4a 100644 --- a/apps/backend/src/lib/GetRequestUser.spec.ts +++ b/apps/backend/src/lib/GetRequestUser.spec.ts @@ -18,7 +18,7 @@ describe('GetRequestToken', () => { describe('validateUser', () => { it('should return the user if the user exists', () => { const mockUser = { - _id: 'test-id', + _id : 'test-id', username: 'testuser', } as unknown as UserDocument; diff --git a/apps/backend/src/lib/initializeSwagger.spec.ts b/apps/backend/src/lib/initializeSwagger.spec.ts index 8792226b..ef396009 100644 --- a/apps/backend/src/lib/initializeSwagger.spec.ts +++ b/apps/backend/src/lib/initializeSwagger.spec.ts @@ -1,20 +1,21 @@ +import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; + import { INestApplication } from '@nestjs/common'; import { SwaggerModule } from '@nestjs/swagger'; -import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; import { initializeSwagger } from './initializeSwagger'; mock.module('@nestjs/swagger', () => ({ DocumentBuilder: jest.fn().mockImplementation(() => ({ - setTitle: jest.fn().mockReturnThis(), + setTitle : jest.fn().mockReturnThis(), setDescription: jest.fn().mockReturnThis(), - setVersion: jest.fn().mockReturnThis(), - addBearerAuth: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), + setVersion : jest.fn().mockReturnThis(), + addBearerAuth : jest.fn().mockReturnThis(), + build : jest.fn().mockReturnValue({}), })), SwaggerModule: { createDocument: jest.fn().mockReturnValue({}), - setup: jest.fn(), + setup : jest.fn(), }, })); @@ -34,7 +35,7 @@ describe('initializeSwagger', () => { ); expect(SwaggerModule.setup).toHaveBeenCalledWith( - 'api/doc', + 'docs', app, expect.any(Object), { diff --git a/apps/backend/src/lib/parseToken.spec.ts b/apps/backend/src/lib/parseToken.spec.ts index 8db755f3..9d785915 100644 --- a/apps/backend/src/lib/parseToken.spec.ts +++ b/apps/backend/src/lib/parseToken.spec.ts @@ -14,7 +14,7 @@ describe('ParseTokenPipe', () => { providers: [ ParseTokenPipe, { - provide: AuthService, + provide : AuthService, useValue: { getUserFromToken: jest.fn(), }, @@ -34,7 +34,7 @@ describe('ParseTokenPipe', () => { it('should return true if no authorization header is present', async () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnThis(), - getRequest: jest.fn().mockReturnValue({ headers: {} }), + getRequest : jest.fn().mockReturnValue({ headers: {} }), } as unknown as ExecutionContext; const result = await parseTokenPipe.canActivate(mockExecutionContext); @@ -45,7 +45,7 @@ describe('ParseTokenPipe', () => { it('should return true if user is not found from token', async () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnThis(), - getRequest: jest.fn().mockReturnValue({ + getRequest : jest.fn().mockReturnValue({ headers: { authorization: 'Bearer test-token' }, }), } as unknown as ExecutionContext; @@ -63,8 +63,8 @@ describe('ParseTokenPipe', () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnThis(), - getRequest: jest.fn().mockReturnValue({ - headers: { authorization: 'Bearer test-token' }, + getRequest : jest.fn().mockReturnValue({ + headers : { authorization: 'Bearer test-token' }, existingUser: null, }), } as unknown as ExecutionContext; diff --git a/apps/backend/src/mailing/mailing.controller.spec.ts b/apps/backend/src/mailing/mailing.controller.spec.ts index cebfd371..1e1633c0 100644 --- a/apps/backend/src/mailing/mailing.controller.spec.ts +++ b/apps/backend/src/mailing/mailing.controller.spec.ts @@ -9,9 +9,9 @@ describe('MailingController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [MailingController], - providers: [ + providers : [ { - provide: MailingService, + provide : MailingService, useValue: {}, }, ], diff --git a/apps/backend/src/mailing/mailing.module.ts b/apps/backend/src/mailing/mailing.module.ts index fb737e0e..e710ad3e 100644 --- a/apps/backend/src/mailing/mailing.module.ts +++ b/apps/backend/src/mailing/mailing.module.ts @@ -5,7 +5,7 @@ import { MailingService } from './mailing.service'; @Module({ controllers: [MailingController], - providers: [MailingService], - exports: [MailingService], + providers : [MailingService], + exports : [MailingService], }) export class MailingModule {} diff --git a/apps/backend/src/mailing/mailing.service.spec.ts b/apps/backend/src/mailing/mailing.service.spec.ts index 4918150e..6d29f30c 100644 --- a/apps/backend/src/mailing/mailing.service.spec.ts +++ b/apps/backend/src/mailing/mailing.service.spec.ts @@ -16,7 +16,7 @@ describe('MailingService', () => { providers: [ MailingService, { - provide: MailerService, + provide : MailerService, useValue: MockedMailerService, }, ], @@ -40,7 +40,7 @@ describe('MailingService', () => { const template = 'hello'; const context = { - name: 'John Doe', + name : 'John Doe', message: 'Hello, this is a test email!', }; @@ -54,13 +54,13 @@ describe('MailingService', () => { attachments: [ { filename: 'background-image.png', - cid: 'background-image', - path: `${__dirname}/templates/img/background-image.png`, + cid : 'background-image', + path : `${__dirname}/templates/img/background-image.png`, }, { filename: 'logo.png', - cid: 'logo', - path: `${__dirname}/templates/img/logo.png`, + cid : 'logo', + path : `${__dirname}/templates/img/logo.png`, }, ], }); diff --git a/apps/backend/src/mailing/mailing.service.ts b/apps/backend/src/mailing/mailing.service.ts index 8fd5b447..41c919df 100644 --- a/apps/backend/src/mailing/mailing.service.ts +++ b/apps/backend/src/mailing/mailing.service.ts @@ -28,18 +28,18 @@ export class MailingService { await this.mailerService.sendMail({ to, subject, - template: `${template}`, // The template file name (without extension) + template : `${template}`, // The template file name (without extension) context, // The context to be passed to the template attachments: [ { filename: 'background-image.png', - cid: 'background-image', - path: `${__dirname}/templates/img/background-image.png`, + cid : 'background-image', + path : `${__dirname}/templates/img/background-image.png`, }, { filename: 'logo.png', - cid: 'logo', - path: `${__dirname}/templates/img/logo.png`, + cid : 'logo', + path : `${__dirname}/templates/img/logo.png`, }, ], }); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 389ee3c0..bb562f51 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -18,7 +18,7 @@ async function bootstrap() { app.useGlobalPipes( new ValidationPipe({ - transform: true, + transform : true, transformOptions: { enableImplicitConversion: true, }, @@ -36,8 +36,8 @@ async function bootstrap() { app.enableCors({ allowedHeaders: ['content-type', 'authorization', 'src'], exposedHeaders: ['Content-Disposition'], - origin: [process.env.FRONTEND_URL || '', 'https://bentroen.github.io'], - credentials: true, + origin : [process.env.FRONTEND_URL || '', 'https://bentroen.github.io'], + credentials : true, }); app.use('/v1', express.static('public')); diff --git a/apps/backend/src/seed/seed.controller.spec.ts b/apps/backend/src/seed/seed.controller.spec.ts index 63cc91ea..9c158e5b 100644 --- a/apps/backend/src/seed/seed.controller.spec.ts +++ b/apps/backend/src/seed/seed.controller.spec.ts @@ -9,9 +9,9 @@ describe('SeedController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SeedController], - providers: [ + providers : [ { - provide: SeedService, + provide : SeedService, useValue: {}, }, ], diff --git a/apps/backend/src/seed/seed.module.ts b/apps/backend/src/seed/seed.module.ts index bf4ded79..b3123f67 100644 --- a/apps/backend/src/seed/seed.module.ts +++ b/apps/backend/src/seed/seed.module.ts @@ -21,13 +21,13 @@ export class SeedModule { } else { SeedModule.logger.warn('Seeding is allowed in development mode'); return { - module: SeedModule, - imports: [UserModule, SongModule, ConfigModule.forRoot()], + module : SeedModule, + imports : [UserModule, SongModule, ConfigModule.forRoot()], providers: [ ConfigService, SeedService, { - provide: 'NODE_ENV', + provide : 'NODE_ENV', useFactory: (configService: ConfigService) => configService.get('NODE_ENV'), inject: [ConfigService], diff --git a/apps/backend/src/seed/seed.service.spec.ts b/apps/backend/src/seed/seed.service.spec.ts index 7577e9c4..c894af4c 100644 --- a/apps/backend/src/seed/seed.service.spec.ts +++ b/apps/backend/src/seed/seed.service.spec.ts @@ -13,19 +13,19 @@ describe('SeedService', () => { providers: [ SeedService, { - provide: 'NODE_ENV', + provide : 'NODE_ENV', useValue: 'development', }, { - provide: UserService, + provide : UserService, useValue: { createWithPassword: jest.fn(), }, }, { - provide: SongService, + provide : SongService, useValue: { - uploadSong: jest.fn(), + uploadSong : jest.fn(), getSongById: jest.fn(), }, }, diff --git a/apps/backend/src/seed/seed.service.ts b/apps/backend/src/seed/seed.service.ts index af3798eb..2f279d7f 100644 --- a/apps/backend/src/seed/seed.service.ts +++ b/apps/backend/src/seed/seed.service.ts @@ -65,21 +65,21 @@ export class SeedService { user.description = faker.lorem.paragraph(); user.socialLinks = { - youtube: faker.internet.url(), - x: faker.internet.url(), - discord: faker.internet.url(), - instagram: faker.internet.url(), - twitch: faker.internet.url(), - bandcamp: faker.internet.url(), - facebook: faker.internet.url(), - github: faker.internet.url(), - reddit: faker.internet.url(), - snapchat: faker.internet.url(), + youtube : faker.internet.url(), + x : faker.internet.url(), + discord : faker.internet.url(), + instagram : faker.internet.url(), + twitch : faker.internet.url(), + bandcamp : faker.internet.url(), + facebook : faker.internet.url(), + github : faker.internet.url(), + reddit : faker.internet.url(), + snapchat : faker.internet.url(), soundcloud: faker.internet.url(), - spotify: faker.internet.url(), - steam: faker.internet.url(), - telegram: faker.internet.url(), - tiktok: faker.internet.url(), + spotify : faker.internet.url(), + steam : faker.internet.url(), + telegram : faker.internet.url(), + tiktok : faker.internet.url(), }; // remove some social links randomly to simulate users not having all of them or having none @@ -109,26 +109,26 @@ export class SeedService { const body: UploadSongDto = { file: { - buffer: fileData, - size: fileBuffer.length, - mimetype: 'application/octet-stream', + buffer : fileData, + size : fileBuffer.length, + mimetype : 'application/octet-stream', originalname: `${faker.music.songName()}.nbs`, }, allowDownload: faker.datatype.boolean(), - visibility: faker.helpers.arrayElement( + visibility : faker.helpers.arrayElement( visibilities, ) as VisibilityType, - title: faker.music.songName(), - originalAuthor: faker.music.artist(), - description: faker.lorem.paragraph(), - license: faker.helpers.arrayElement(licenses) as LicenseType, - category: faker.helpers.arrayElement(categories) as CategoryType, + title : faker.music.songName(), + originalAuthor : faker.music.artist(), + description : faker.lorem.paragraph(), + license : faker.helpers.arrayElement(licenses) as LicenseType, + category : faker.helpers.arrayElement(categories) as CategoryType, customInstruments: [], - thumbnailData: { + thumbnailData : { backgroundColor: faker.internet.color(), - startLayer: faker.helpers.rangeToNumber({ min: 0, max: 4 }), - startTick: faker.helpers.rangeToNumber({ min: 0, max: 100 }), - zoomLevel: faker.helpers.rangeToNumber({ min: 1, max: 5 }), + startLayer : faker.helpers.rangeToNumber({ min: 0, max: 4 }), + startTick : faker.helpers.rangeToNumber({ min: 0, max: 100 }), + zoomLevel : faker.helpers.rangeToNumber({ min: 1, max: 5 }), }, }; @@ -197,10 +197,10 @@ export class SeedService { }).map( () => new Note(instrument, { - key: faker.helpers.rangeToNumber({ min: 0, max: 127 }), + key : faker.helpers.rangeToNumber({ min: 0, max: 127 }), velocity: faker.helpers.rangeToNumber({ min: 0, max: 127 }), - panning: faker.helpers.rangeToNumber({ min: -1, max: 1 }), - pitch: faker.helpers.rangeToNumber({ min: -1, max: 1 }), + panning : faker.helpers.rangeToNumber({ min: -1, max: 1 }), + pitch : faker.helpers.rangeToNumber({ min: -1, max: 1 }), }), ); @@ -216,7 +216,7 @@ export class SeedService { return new Date( faker.date.between({ from: from.getTime(), - to: to.getTime(), + to : to.getTime(), }), ); } diff --git a/apps/backend/src/song-browser/song-browser.controller.spec.ts b/apps/backend/src/song-browser/song-browser.controller.spec.ts deleted file mode 100644 index 0e95d2ff..00000000 --- a/apps/backend/src/song-browser/song-browser.controller.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { SongBrowserController } from './song-browser.controller'; -import { SongBrowserService } from './song-browser.service'; - -const mockSongBrowserService = { - getFeaturedSongs: jest.fn(), - getRecentSongs: jest.fn(), - getCategories: jest.fn(), - getSongsByCategory: jest.fn(), -}; - -describe('SongBrowserController', () => { - let controller: SongBrowserController; - let songBrowserService: SongBrowserService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SongBrowserController], - providers: [ - { - provide: SongBrowserService, - useValue: mockSongBrowserService, - }, - ], - }).compile(); - - controller = module.get(SongBrowserController); - songBrowserService = module.get(SongBrowserService); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('getFeaturedSongs', () => { - it('should return a list of featured songs', async () => { - const featuredSongs: FeaturedSongsDto = {} as FeaturedSongsDto; - - mockSongBrowserService.getFeaturedSongs.mockResolvedValueOnce( - featuredSongs, - ); - - const result = await controller.getFeaturedSongs(); - - expect(result).toEqual(featuredSongs); - expect(songBrowserService.getFeaturedSongs).toHaveBeenCalled(); - }); - }); - - describe('getSongList', () => { - it('should return a list of recent songs', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - const songList: SongPreviewDto[] = []; - - mockSongBrowserService.getRecentSongs.mockResolvedValueOnce(songList); - - const result = await controller.getSongList(query); - - expect(result).toEqual(songList); - expect(songBrowserService.getRecentSongs).toHaveBeenCalledWith(query); - }); - }); - - describe('getCategories', () => { - it('should return a list of song categories and song counts', async () => { - const categories: Record = { - category1: 10, - category2: 5, - }; - - mockSongBrowserService.getCategories.mockResolvedValueOnce(categories); - - const result = await controller.getCategories(); - - expect(result).toEqual(categories); - expect(songBrowserService.getCategories).toHaveBeenCalled(); - }); - }); - - describe('getSongsByCategory', () => { - it('should return a list of songs by category', async () => { - const id = 'test-category'; - const query: PageQueryDTO = { page: 1, limit: 10 }; - const songList: SongPreviewDto[] = []; - - mockSongBrowserService.getSongsByCategory.mockResolvedValueOnce(songList); - - const result = await controller.getSongsByCategory(id, query); - - expect(result).toEqual(songList); - - expect(songBrowserService.getSongsByCategory).toHaveBeenCalledWith( - id, - query, - ); - }); - }); -}); diff --git a/apps/backend/src/song-browser/song-browser.controller.ts b/apps/backend/src/song-browser/song-browser.controller.ts deleted file mode 100644 index f9746d1e..00000000 --- a/apps/backend/src/song-browser/song-browser.controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; -import { - BadRequestException, - Controller, - Get, - Param, - Query, -} from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { SongBrowserService } from './song-browser.service'; - -@Controller('song-browser') -@ApiTags('song-browser') -@ApiTags('song') -export class SongBrowserController { - constructor(public readonly songBrowserService: SongBrowserService) {} - - @Get('/featured') - @ApiOperation({ summary: 'Get a list of featured songs' }) - public async getFeaturedSongs(): Promise { - return await this.songBrowserService.getFeaturedSongs(); - } - - @Get('/recent') - @ApiOperation({ - summary: 'Get a filtered/sorted list of recent songs with pagination', - }) - public async getSongList( - @Query() query: PageQueryDTO, - ): Promise { - return await this.songBrowserService.getRecentSongs(query); - } - - @Get('/categories') - @ApiOperation({ summary: 'Get a list of song categories and song counts' }) - public async getCategories(): Promise> { - return await this.songBrowserService.getCategories(); - } - - @Get('/categories/:id') - @ApiOperation({ summary: 'Get a list of song categories and song counts' }) - public async getSongsByCategory( - @Param('id') id: string, - @Query() query: PageQueryDTO, - ): Promise { - return await this.songBrowserService.getSongsByCategory(id, query); - } - - @Get('/random') - @ApiOperation({ summary: 'Get a list of songs at random' }) - public async getRandomSongs( - @Query('count') count: string, - @Query('category') category: string, - ): Promise { - const countInt = parseInt(count); - - if (isNaN(countInt) || countInt < 1 || countInt > 10) { - throw new BadRequestException('Invalid query parameters'); - } - - return await this.songBrowserService.getRandomSongs(countInt, category); - } -} diff --git a/apps/backend/src/song-browser/song-browser.module.ts b/apps/backend/src/song-browser/song-browser.module.ts deleted file mode 100644 index f1eb25e0..00000000 --- a/apps/backend/src/song-browser/song-browser.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { SongModule } from '@server/song/song.module'; - -import { SongBrowserController } from './song-browser.controller'; -import { SongBrowserService } from './song-browser.service'; - -@Module({ - providers: [SongBrowserService], - controllers: [SongBrowserController], - imports: [SongModule], -}) -export class SongBrowserModule {} diff --git a/apps/backend/src/song-browser/song-browser.service.spec.ts b/apps/backend/src/song-browser/song-browser.service.spec.ts deleted file mode 100644 index f46f98d6..00000000 --- a/apps/backend/src/song-browser/song-browser.service.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { PageQueryDTO, SongPreviewDto, SongWithUser } from '@nbw/database'; -import { HttpException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { SongService } from '@server/song/song.service'; - -import { SongBrowserService } from './song-browser.service'; - -const mockSongService = { - getSongsForTimespan: jest.fn(), - getSongsBeforeTimespan: jest.fn(), - getRecentSongs: jest.fn(), - getCategories: jest.fn(), - getSongsByCategory: jest.fn(), -}; - -describe('SongBrowserService', () => { - let service: SongBrowserService; - let songService: SongService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SongBrowserService, - { - provide: SongService, - useValue: mockSongService, - }, - ], - }).compile(); - - service = module.get(SongBrowserService); - songService = module.get(SongService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getFeaturedSongs', () => { - it('should return featured songs', async () => { - const songWithUser: SongWithUser = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - stats: { - duration: 100, - noteCount: 100, - }, - } as any; - - jest - .spyOn(songService, 'getSongsForTimespan') - .mockResolvedValue([songWithUser]); - - jest - .spyOn(songService, 'getSongsBeforeTimespan') - .mockResolvedValue([songWithUser]); - - await service.getFeaturedSongs(); - - expect(songService.getSongsForTimespan).toHaveBeenCalled(); - expect(songService.getSongsBeforeTimespan).toHaveBeenCalled(); - }); - }); - - describe('getRecentSongs', () => { - it('should return recent songs', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - - const songPreviewDto: SongPreviewDto = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as any; - - jest - .spyOn(songService, 'getRecentSongs') - .mockResolvedValue([songPreviewDto]); - - const result = await service.getRecentSongs(query); - - expect(result).toEqual([songPreviewDto]); - - expect(songService.getRecentSongs).toHaveBeenCalledWith( - query.page, - query.limit, - ); - }); - - it('should throw an error if query parameters are invalid', async () => { - const query: PageQueryDTO = { page: undefined, limit: undefined }; - - await expect(service.getRecentSongs(query)).rejects.toThrow( - HttpException, - ); - }); - }); - - describe('getCategories', () => { - it('should return categories', async () => { - const categories = { pop: 10, rock: 5 }; - - jest.spyOn(songService, 'getCategories').mockResolvedValue(categories); - - const result = await service.getCategories(); - - expect(result).toEqual(categories); - expect(songService.getCategories).toHaveBeenCalled(); - }); - }); - - describe('getSongsByCategory', () => { - it('should return songs by category', async () => { - const category = 'pop'; - const query: PageQueryDTO = { page: 1, limit: 10 }; - - const songPreviewDto: SongPreviewDto = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as any; - - jest - .spyOn(songService, 'getSongsByCategory') - .mockResolvedValue([songPreviewDto]); - - const result = await service.getSongsByCategory(category, query); - - expect(result).toEqual([songPreviewDto]); - - expect(songService.getSongsByCategory).toHaveBeenCalledWith( - category, - query.page, - query.limit, - ); - }); - }); -}); diff --git a/apps/backend/src/song-browser/song-browser.service.ts b/apps/backend/src/song-browser/song-browser.service.ts deleted file mode 100644 index 0739d5c0..00000000 --- a/apps/backend/src/song-browser/song-browser.service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BROWSER_SONGS } from '@nbw/config'; -import { - FeaturedSongsDto, - PageQueryDTO, - SongPreviewDto, - SongWithUser, - TimespanType, -} from '@nbw/database'; -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; - -import { SongService } from '@server/song/song.service'; - -@Injectable() -export class SongBrowserService { - constructor( - @Inject(SongService) - private songService: SongService, - ) {} - - public async getFeaturedSongs(): Promise { - const now = new Date(Date.now()); - - const times: Record = { - hour: new Date(Date.now()).setHours(now.getHours() - 1), - day: new Date(Date.now()).setDate(now.getDate() - 1), - week: new Date(Date.now()).setDate(now.getDate() - 7), - month: new Date(Date.now()).setMonth(now.getMonth() - 1), - year: new Date(Date.now()).setFullYear(now.getFullYear() - 1), - all: new Date(0).getTime(), - }; - - const songs: Record = { - hour: [], - day: [], - week: [], - month: [], - year: [], - all: [], - }; - - for (const [timespan, time] of Object.entries(times)) { - const songPage = await this.songService.getSongsForTimespan(time); - - // If the length is 0, send an empty array (no songs available in that timespan) - // If the length is less than the page size, pad it with songs "borrowed" - // from the nearest timestamp, regardless of view count - if ( - songPage.length > 0 && - songPage.length < BROWSER_SONGS.paddedFeaturedPageSize - ) { - const missing = BROWSER_SONGS.paddedFeaturedPageSize - songPage.length; - - const additionalSongs = await this.songService.getSongsBeforeTimespan( - time, - ); - - songPage.push(...additionalSongs.slice(0, missing)); - } - - songs[timespan as TimespanType] = songPage; - } - - const featuredSongs = FeaturedSongsDto.create(); - - featuredSongs.hour = songs.hour.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.day = songs.day.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.week = songs.week.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.month = songs.month.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.year = songs.year.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.all = songs.all.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - return featuredSongs; - } - - public async getRecentSongs(query: PageQueryDTO): Promise { - const { page, limit } = query; - - if (!page || !limit) { - throw new HttpException( - 'Invalid query parameters', - HttpStatus.BAD_REQUEST, - ); - } - - return await this.songService.getRecentSongs(page, limit); - } - - public async getCategories(): Promise> { - return await this.songService.getCategories(); - } - - public async getSongsByCategory( - category: string, - query: PageQueryDTO, - ): Promise { - return await this.songService.getSongsByCategory( - category, - query.page ?? 1, - query.limit ?? 10, - ); - } - - public async getRandomSongs( - count: number, - category: string, - ): Promise { - return await this.songService.getRandomSongs(count, category); - } -} diff --git a/apps/backend/src/song/my-songs/my-songs.controller.spec.ts b/apps/backend/src/song/my-songs/my-songs.controller.spec.ts index 25b3d33f..541b27fb 100644 --- a/apps/backend/src/song/my-songs/my-songs.controller.spec.ts +++ b/apps/backend/src/song/my-songs/my-songs.controller.spec.ts @@ -5,6 +5,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { SongService } from '../song.service'; + import { MySongsController } from './my-songs.controller'; const mockSongService = { @@ -18,9 +19,9 @@ describe('MySongsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [MySongsController], - providers: [ + providers : [ { - provide: SongService, + provide : SongService, useValue: mockSongService, }, ], @@ -44,9 +45,9 @@ describe('MySongsController', () => { const songPageDto: SongPageDto = { content: [], - page: 0, - limit: 0, - total: 0, + page : 0, + limit : 0, + total : 0, }; mockSongService.getMySongsPage.mockResolvedValueOnce(songPageDto); diff --git a/apps/backend/src/song/song-upload/song-upload.service.spec.ts b/apps/backend/src/song/song-upload/song-upload.service.spec.ts index c58c0d22..a78388a4 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.spec.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.spec.ts @@ -1,3 +1,5 @@ +import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; + import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; import type { UserDocument } from '@nbw/database'; import { @@ -8,7 +10,6 @@ import { } from '@nbw/database'; import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; import { Types } from 'mongoose'; import { FileService } from '@server/file/file.service'; @@ -22,10 +23,10 @@ mock.module('@nbw/thumbnail', () => ({ })); const mockFileService = { - uploadSong: jest.fn(), + uploadSong : jest.fn(), uploadPackedSong: jest.fn(), - uploadThumbnail: jest.fn(), - getSongFile: jest.fn(), + uploadThumbnail : jest.fn(), + getSongFile : jest.fn(), }; const mockUserService = { @@ -42,11 +43,11 @@ describe('SongUploadService', () => { providers: [ SongUploadService, { - provide: FileService, + provide : FileService, useValue: mockFileService, }, { - provide: UserService, + provide : UserService, useValue: mockUserService, }, ], @@ -66,26 +67,26 @@ describe('SongUploadService', () => { const file = { buffer: Buffer.from('test') } as Express.Multer.File; const user: UserDocument = { - _id: new Types.ObjectId(), + _id : new Types.ObjectId(), username: 'testuser', } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - file: 'somebytes', + file : 'somebytes', }; const songEntity = new SongEntity(); @@ -96,7 +97,7 @@ describe('SongUploadService', () => { ); spyOn(songUploadService as any, 'prepareSongForUpload').mockReturnValue({ - nbsSong: new Song(), + nbsSong : new Song(), songBuffer: Buffer.from('test'), }); @@ -161,42 +162,42 @@ describe('SongUploadService', () => { describe('processSongPatch', () => { it('should process and patch a song', async () => { const user: UserDocument = { - _id: new Types.ObjectId(), + _id : new Types.ObjectId(), username: 'testuser', } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - file: 'somebytes', + file : 'somebytes', }; const songDocument: SongDocument = { ...body, - publicId: 'test-id', - uploader: user._id, + publicId : 'test-id', + uploader : user._id, customInstruments: [], - thumbnailData: body.thumbnailData, - nbsFileUrl: 'http://test.com/file.nbs', - save: jest.fn().mockResolvedValue({}), + thumbnailData : body.thumbnailData, + nbsFileUrl : 'http://test.com/file.nbs', + save : jest.fn().mockResolvedValue({}), } as any; spyOn(fileService, 'getSongFile').mockResolvedValue(new ArrayBuffer(0)); spyOn(songUploadService as any, 'prepareSongForUpload').mockReturnValue({ - nbsSong: new Song(), + nbsSong : new Song(), songBuffer: Buffer.from('test'), }); @@ -224,9 +225,9 @@ describe('SongUploadService', () => { describe('generateAndUploadThumbnail', () => { it('should generate and upload a thumbnail', async () => { const thumbnailData: ThumbnailData = { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }; @@ -255,9 +256,9 @@ describe('SongUploadService', () => { it('should throw an error if the thumbnail is invalid', async () => { const thumbnailData: ThumbnailData = { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }; @@ -353,10 +354,10 @@ describe('SongUploadService', () => { const songTest = new Song(); songTest.meta = { - author: 'Nicolas Vycas', - description: 'super cool song', - importName: 'test', - name: 'Cool Test Song', + author : 'Nicolas Vycas', + description : 'super cool song', + importName : 'test', + name : 'Cool Test Song', originalAuthor: 'Nicolas Vycas', }; diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index bbf37b9d..6be00500 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -1,26 +1,8 @@ import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js'; -import { - SongDocument, - Song as SongEntity, - SongStats, - ThumbnailData, - UploadSongDto, - UserDocument, -} from '@nbw/database'; -import { - NoteQuadTree, - SongStatsGenerator, - injectSongFileMetadata, - obfuscateAndPackSong, -} from '@nbw/song'; +import { SongDocument, Song as SongEntity, SongStats, ThumbnailData, UploadSongDto, UserDocument, } from '@nbw/database'; +import { NoteQuadTree, SongStatsGenerator, injectSongFileMetadata, obfuscateAndPackSong, } from '@nbw/song'; import { drawToImage } from '@nbw/thumbnail'; -import { - HttpException, - HttpStatus, - Inject, - Injectable, - Logger, -} from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable, Logger, } from '@nestjs/common'; import { Types } from 'mongoose'; import { FileService } from '@server/file/file.service'; @@ -115,7 +97,7 @@ export class SongUploadService { song.originalAuthor = removeExtraSpaces(body.originalAuthor); song.description = removeExtraSpaces(body.description); song.category = body.category; - song.allowDownload = true || body.allowDownload; //TODO: implement allowDownload; + song.allowDownload = true ;//|| body.allowDownload; //TODO: implement allowDownload; song.visibility = body.visibility; song.license = body.license; @@ -360,13 +342,13 @@ export class SongUploadService { const quadTree = new NoteQuadTree(nbsSong); const thumbBuffer = await drawToImage({ - notes: quadTree, - startTick: startTick, - startLayer: startLayer, - zoomLevel: zoomLevel, + notes : quadTree, + startTick : startTick, + startLayer : startLayer, + zoomLevel : zoomLevel, backgroundColor: backgroundColor, - imgWidth: 1280, - imgHeight: 768, + imgWidth : 1280, + imgHeight : 768, }); // Upload thumbnail @@ -446,7 +428,7 @@ export class SongUploadService { throw new HttpException( { error: { - file: 'Invalid NBS file', + file : 'Invalid NBS file', errors: nbsSong.errors, }, }, diff --git a/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts b/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts index 8b09fef1..158b0912 100644 --- a/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts +++ b/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts @@ -1,11 +1,13 @@ +import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; + import { Song as SongEntity, SongWithUser } from '@nbw/database'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; import { Model } from 'mongoose'; import { getUploadDiscordEmbed } from '../song.util'; + import { SongWebhookService } from './song-webhook.service'; mock.module('../song.util', () => ({ @@ -13,10 +15,10 @@ mock.module('../song.util', () => ({ })); const mockSongModel = { - find: jest.fn().mockReturnThis(), - sort: jest.fn().mockReturnThis(), + find : jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), populate: jest.fn().mockReturnThis(), - save: jest.fn(), + save : jest.fn(), }; describe('SongWebhookService', () => { @@ -26,15 +28,15 @@ describe('SongWebhookService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot()], + imports : [ConfigModule.forRoot()], providers: [ SongWebhookService, { - provide: getModelToken(SongEntity.name), + provide : getModelToken(SongEntity.name), useValue: mockSongModel, }, { - provide: 'DISCORD_WEBHOOK_URL', + provide : 'DISCORD_WEBHOOK_URL', useValue: 'http://localhost/webhook', }, ], @@ -67,7 +69,7 @@ describe('SongWebhookService', () => { expect(result).toBe('message-id'); expect(fetch).toHaveBeenCalledWith('http://localhost/webhook?wait=true', { - method: 'POST', + method : 'POST', headers: { 'Content-Type': 'application/json', }, @@ -94,9 +96,9 @@ describe('SongWebhookService', () => { describe('updateSongWebhook', () => { it('should update the webhook message for a song', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); @@ -108,7 +110,7 @@ describe('SongWebhookService', () => { expect(fetch).toHaveBeenCalledWith( 'http://localhost/webhook/messages/message-id', { - method: 'PATCH', + method : 'PATCH', headers: { 'Content-Type': 'application/json', }, @@ -119,9 +121,9 @@ describe('SongWebhookService', () => { it('should log an error if there is an error', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); @@ -142,9 +144,9 @@ describe('SongWebhookService', () => { describe('deleteSongWebhook', () => { it('should delete the webhook message for a song', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; (global as any).fetch = jest.fn().mockResolvedValue({}); @@ -161,9 +163,9 @@ describe('SongWebhookService', () => { it('should log an error if there is an error', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; (global as any).fetch = jest.fn().mockRejectedValue(new Error('Error')); @@ -182,10 +184,10 @@ describe('SongWebhookService', () => { describe('syncSongWebhook', () => { it('should update the webhook message if the song is public', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', webhookMessageId: 'message-id', - visibility: 'public', - uploader: { username: 'testuser', profileImage: 'testimage' }, + visibility : 'public', + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; const updateSpy = spyOn(service, 'updateSongWebhook'); @@ -197,10 +199,10 @@ describe('SongWebhookService', () => { it('should delete the webhook message if the song is not public', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', webhookMessageId: 'message-id', - visibility: 'private', - uploader: { username: 'testuser', profileImage: 'testimage' }, + visibility : 'private', + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; const deleteSpy = spyOn(service, 'deleteSongWebhook'); @@ -212,9 +214,9 @@ describe('SongWebhookService', () => { it('should post a new webhook message if the song is public and does not have a message', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', visibility: 'public', - uploader: { username: 'testuser', profileImage: 'testimage' }, + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; const postSpy = spyOn(service, 'postSongWebhook'); @@ -226,9 +228,9 @@ describe('SongWebhookService', () => { it('should return null if the song is not public and does not have a message', async () => { const song: SongWithUser = { - publicId: '123', + publicId : '123', visibility: 'private', - uploader: { username: 'testuser', profileImage: 'testimage' }, + uploader : { username: 'testuser', profileImage: 'testimage' }, } as SongWithUser; const result = await service.syncSongWebhook(song); @@ -243,12 +245,12 @@ describe('SongWebhookService', () => { { publicId: '123', uploader: { username: 'testuser', profileImage: 'testimage' }, - save: jest.fn(), + save : jest.fn(), } as unknown as SongWithUser, ]; mockSongModel.find.mockReturnValue({ - sort: jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), populate: jest.fn().mockResolvedValue(songs), }); diff --git a/apps/backend/src/song/song-webhook/song-webhook.service.ts b/apps/backend/src/song/song-webhook/song-webhook.service.ts index 90e16745..18681e94 100644 --- a/apps/backend/src/song/song-webhook/song-webhook.service.ts +++ b/apps/backend/src/song/song-webhook/song-webhook.service.ts @@ -41,7 +41,7 @@ export class SongWebhookService implements OnModuleInit { try { const response = await fetch(`${webhookUrl}?wait=true`, { - method: 'POST', + method : 'POST', headers: { 'Content-Type': 'application/json', }, @@ -84,7 +84,7 @@ export class SongWebhookService implements OnModuleInit { try { await fetch(`${webhookUrl}/messages/${song.webhookMessageId}`, { - method: 'PATCH', + method : 'PATCH', headers: { 'Content-Type': 'application/json', }, diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts index f4fc3ac2..5f151a76 100644 --- a/apps/backend/src/song/song.controller.spec.ts +++ b/apps/backend/src/song/song.controller.spec.ts @@ -1,29 +1,24 @@ import type { UserDocument } from '@nbw/database'; -import { - PageQueryDTO, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, -} from '@nbw/database'; +import { PageQueryDTO, SongPreviewDto, SongViewDto, UploadSongDto, UploadSongResponseDto, } from '@nbw/database'; import { HttpStatus, UnauthorizedException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { Response } from 'express'; -import { FileService } from '@server/file/file.service'; +import { FileService } from '../file/file.service'; import { SongController } from './song.controller'; import { SongService } from './song.service'; const mockSongService = { - getSongByPage: jest.fn(), - getSong: jest.fn(), - getSongEdit: jest.fn(), - patchSong: jest.fn(), + getSongByPage : jest.fn(), + searchSongs : jest.fn(), + getSong : jest.fn(), + getSongEdit : jest.fn(), + patchSong : jest.fn(), getSongDownloadUrl: jest.fn(), - deleteSong: jest.fn(), - uploadSong: jest.fn(), + deleteSong : jest.fn(), + uploadSong : jest.fn(), }; const mockFileService = {}; @@ -35,13 +30,13 @@ describe('SongController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SongController], - providers: [ + providers : [ { - provide: SongService, + provide : SongService, useValue: mockSongService, }, { - provide: FileService, + provide : FileService, useValue: mockFileService, }, ], @@ -52,6 +47,9 @@ describe('SongController', () => { songController = module.get(SongController); songService = module.get(SongService); + + // Clear all mocks + jest.clearAllMocks(); }); it('should be defined', () => { @@ -71,6 +69,83 @@ describe('SongController', () => { expect(songService.getSongByPage).toHaveBeenCalledWith(query); }); + it('should handle featured songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + const result = await songController.getSongList(query, 'featured'); + + expect(result).toEqual(songList); + }); + + it('should handle recent songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + + const result = await songController.getSongList(query, 'recent'); + + expect(result).toEqual(songList); + }); + + it('should return categories when q=categories without id', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const categories = { pop: 42, rock: 38 }; + + + const result = await songController.getSongList(query, 'categories'); + + expect(result).toEqual(categories); + }); + + it('should return songs by category when q=categories with id', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + const categoryId = 'pop'; + + + const result = await songController.getSongList(query, 'categories', categoryId); + + expect(result).toEqual(songList); + }); + + it('should return random songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 5 }; + const songList: SongPreviewDto[] = []; + const category = 'electronic'; + + + const result = await songController.getSongList(query, 'random', undefined, category); + + expect(result).toEqual(songList); + }); + + it('should throw error for invalid random count', async () => { + const query: PageQueryDTO = { page: 1, limit: 15 }; // Invalid limit > 10 + + await expect( + songController.getSongList(query, 'random') + ).rejects.toThrow('Invalid query parameters'); + }); + + it('should handle zero limit for random (uses default)', async () => { + const query: PageQueryDTO = { page: 1, limit: 0 }; // limit 0 is falsy, so uses default + const songList: SongPreviewDto[] = []; + + + const result = await songController.getSongList(query, 'random'); + + expect(result).toEqual(songList); + }); + + it('should throw error for invalid query mode', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + + await expect( + songController.getSongList(query, 'invalid' as any) + ).rejects.toThrow('Invalid query parameters'); + }); + it('should handle errors', async () => { const query: PageQueryDTO = { page: 1, limit: 10 }; @@ -165,7 +240,7 @@ describe('SongController', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const res = { - set: jest.fn(), + set : jest.fn(), redirect: jest.fn(), } as unknown as Response; @@ -176,7 +251,7 @@ describe('SongController', () => { await songController.getSongFile(id, src, user, res); expect(res.set).toHaveBeenCalledWith({ - 'Content-Disposition': 'attachment; filename="song.nbs"', + 'Content-Disposition' : 'attachment; filename="song.nbs"', 'Access-Control-Expose-Headers': 'Content-Disposition', }); @@ -196,7 +271,7 @@ describe('SongController', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const res = { - set: jest.fn(), + set : jest.fn(), redirect: jest.fn(), } as unknown as Response; @@ -285,20 +360,20 @@ describe('SongController', () => { const file = { buffer: Buffer.from('test') } as Express.Multer.File; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'cc_by_sa', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'cc_by_sa', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, - file: undefined, + file : undefined, allowDownload: false, }; @@ -317,20 +392,20 @@ describe('SongController', () => { const file = { buffer: Buffer.from('test') } as Express.Multer.File; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'cc_by_sa', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'cc_by_sa', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, - file: undefined, + file : undefined, allowDownload: false, }; diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index d6711cee..67118d9f 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -1,41 +1,11 @@ import { UPLOAD_CONSTANTS } from '@nbw/config'; -import type { UserDocument } from '@nbw/database'; -import { - PageQueryDTO, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, -} from '@nbw/database'; +import { PageDto, UserDocument, PageQueryDTO, SongPreviewDto, SongViewDto, UploadSongDto, UploadSongResponseDto, FeaturedSongsDto, } from '@nbw/database'; import type { RawBodyRequest } from '@nestjs/common'; -import { - Body, - Controller, - Delete, - Get, - Headers, - HttpStatus, - Param, - Patch, - Post, - Query, - Req, - Res, - UnauthorizedException, - UploadedFile, - UseGuards, - UseInterceptors, -} from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, Get, Headers, HttpStatus, Param, Patch, Post, Query, Req, Res, UnauthorizedException, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; -import { - ApiBearerAuth, - ApiBody, - ApiConsumes, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags, } from '@nestjs/swagger'; import type { Response } from 'express'; import { FileService } from '@server/file/file.service'; @@ -43,45 +13,150 @@ import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; import { SongService } from './song.service'; -// Handles public-facing song routes. - @Controller('song') @ApiTags('song') export class SongController { static multerConfig: MulterOptions = { - limits: { - fileSize: UPLOAD_CONSTANTS.file.maxSize, - }, + limits : { fileSize: UPLOAD_CONSTANTS.file.maxSize }, fileFilter: (req, file, cb) => { - if (!file.originalname.match(/\.(nbs)$/)) { + if (!file.originalname.match(/\.(nbs)$/)) return cb(new Error('Only .nbs files are allowed!'), false); - } - cb(null, true); }, }; - constructor( - public readonly songService: SongService, - public readonly fileService: FileService, - ) {} + constructor(public readonly songService: SongService, public readonly fileService: FileService,) {} @Get('/') @ApiOperation({ - summary: 'Get a filtered/sorted list of songs with pagination', + summary : 'Get songs with various filtering and browsing options', + description: ` + Retrieves songs based on the provided query parameters. Supports multiple modes: + + **Default mode** (no 'q' parameter): Returns paginated songs with sorting/filtering + + **Special query modes** (using 'q' parameter): + - \`featured\`: Get recent popular songs with pagination + - \`recent\`: Get recently uploaded songs with pagination + - \`categories\`: + - Without 'id': Returns a record of available categories and their song counts + - With 'id': Returns songs from the specified category with pagination + - \`random\`: Returns random songs (requires 'count' parameter, 1-10 songs, optionally filtered by 'category') + + **Query Parameters:** + - Standard pagination/sorting via PageQueryDTO (page, limit, sort, order, timespan) + - \`q\`: Special query mode ('featured', 'recent', 'categories', 'random') + - \`id\`: Category ID (used with q=categories to get songs from specific category) + - \`count\`: Number of random songs to return (1-10, used with q=random) + - \`category\`: Category filter for random songs (used with q=random) + + **Return Types:** + - SongPreviewDto[]: Array of song previews (most cases) + - Record: Category name to count mapping (when q=categories without id) + `, + }) + @ApiQuery({ name: 'q', required: false, enum: ['featured', 'recent', 'categories', 'random'], description: 'Special query mode. If not provided, returns standard paginated song list.', example: 'recent', }) + @ApiParam({ name: 'id', required: false, type: 'string', description: 'Category ID. Only used when q=categories to get songs from a specific category.', example: 'pop', }) + @ApiQuery({ name: 'count', required: false, type: 'string', description: 'Number of random songs to return (1-10). Only used when q=random.', example: '5', }) + @ApiQuery({ name: 'category', required: false, type: 'string', description: 'Category filter for random songs. Only used when q=random.', example: 'electronic', }) + @ApiResponse({ + status : 200, + description: 'Success. Returns either an array of song previews or category counts.', + schema : { + oneOf: [ + { + type : 'array', + items : { $ref: '#/components/schemas/SongPreviewDto' }, + description: 'Array of song previews (default behavior and most query modes)', + }, + { + type : 'object', + additionalProperties: { type: 'number' }, + description : 'Category name to song count mapping (only when q=categories without id)', + example : { pop: 42, rock: 38, electronic: 15 }, + }, + ], + }, + }) + @ApiResponse({ + status : 400, + description: 'Bad Request. Invalid query parameters (e.g., invalid count for random query).', }) public async getSongList( @Query() query: PageQueryDTO, - ): Promise { - return await this.songService.getSongByPage(query); + @Query('q') q?: 'featured' | 'recent' | 'categories' | 'random', + @Param('id') id?: string, + @Query('category') category?: string, + ): Promise | Record | FeaturedSongsDto> { + if (q) { + switch (q) { + case 'featured': + return await this.songService.getFeaturedSongs(); + case 'recent': + return new PageDto({ + content: await this.songService.getRecentSongs(query.page, query.limit,), + page : query.page, + limit : query.limit, + total : 0, + }); + case 'categories': + if (id) { + return new PageDto({ + content: await this.songService.getSongsByCategory( + category, + query.page, + query.limit, + ), + page : query.page, + limit: query.limit, + total: 0, + }); + } + return await this.songService.getCategories(); + case 'random': { + if (query.limit && (query.limit < 1 || query.limit > 10)) { + throw new BadRequestException('Invalid query parameters'); + } + const data = await this.songService.getRandomSongs( + query.limit ?? 1, + category, + ); + return new PageDto({ + content: data, + page : query.page, + limit : query.limit, + total : data.length, + }); + } + default: + throw new BadRequestException('Invalid query parameters'); + } + } + + const data = await this.songService.getSongByPage(query); + return new PageDto({ + content: data, + page : query.page, + limit : query.limit, + total : data.length, + }); + } + + @Get('/search') + @ApiOperation({ summary: 'Search songs by keywords with pagination and sorting', }) + public async searchSongs(@Query() query: PageQueryDTO, @Query('q') q: string,): Promise> { + const data = await this.songService.searchSongs(query, q ?? ''); + return new PageDto({ + content: data, + page : query.page, + limit : query.limit, + total : data.length, + }); } @Get('/:id') @ApiOperation({ summary: 'Get song info by ID' }) - public async getSong( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - ): Promise { + public async getSong(@Param('id') id: string, @GetRequestToken() user: UserDocument | null,): Promise { return await this.songService.getSong(id, user); } @@ -89,10 +164,7 @@ export class SongController { @ApiOperation({ summary: 'Get song info for editing by ID' }) @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() - public async getEditSong( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - ): Promise { + public async getEditSong(@Param('id') id: string, @GetRequestToken() user: UserDocument | null,): Promise { user = validateUser(user); return await this.songService.getSongEdit(id, user); } @@ -101,15 +173,8 @@ export class SongController { @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiOperation({ summary: 'Edit song info by ID' }) - @ApiBody({ - description: 'Upload Song', - type: UploadSongResponseDto, - }) - public async patchSong( - @Param('id') id: string, - @Req() req: RawBodyRequest, - @GetRequestToken() user: UserDocument | null, - ): Promise { + @ApiBody({ description: 'Upload Song', type: UploadSongResponseDto, }) + public async patchSong(@Param('id') id: string, @Req() req: RawBodyRequest, @GetRequestToken() user: UserDocument | null,): Promise { user = validateUser(user); //TODO: Fix this weird type casting and raw body access const body = req.body as unknown as UploadSongDto; @@ -118,17 +183,12 @@ export class SongController { @Get('/:id/download') @ApiOperation({ summary: 'Get song .nbs file' }) - public async getSongFile( - @Param('id') id: string, - @Query('src') src: string, - @GetRequestToken() user: UserDocument | null, - @Res() res: Response, - ): Promise { + public async getSongFile(@Param('id') id: string, @Query('src') src: string, @GetRequestToken() user: UserDocument | null, @Res() res: Response,): Promise { user = validateUser(user); // TODO: no longer used res.set({ - 'Content-Disposition': 'attachment; filename="song.nbs"', + 'Content-Disposition' : 'attachment; filename="song.nbs"', // Expose the Content-Disposition header to the client 'Access-Control-Expose-Headers': 'Content-Disposition', }); @@ -139,11 +199,7 @@ export class SongController { @Get('/:id/open') @ApiOperation({ summary: 'Get song .nbs file' }) - public async getSongOpenUrl( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - @Headers('src') src: string, - ): Promise { + public async getSongOpenUrl(@Param('id') id: string, @GetRequestToken() user: UserDocument | null, @Headers('src') src: string,): Promise { if (src != 'downloadButton') { throw new UnauthorizedException('Invalid source'); } @@ -162,10 +218,7 @@ export class SongController { @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiOperation({ summary: 'Delete a song' }) - public async deleteSong( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - ): Promise { + public async deleteSong(@Param('id') id: string, @GetRequestToken() user: UserDocument | null,): Promise { user = validateUser(user); await this.songService.deleteSong(id, user); } @@ -174,19 +227,10 @@ export class SongController { @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiConsumes('multipart/form-data') - @ApiBody({ - description: 'Upload Song', - type: UploadSongResponseDto, - }) + @ApiBody({ description: 'Upload Song', type: UploadSongResponseDto, }) @UseInterceptors(FileInterceptor('file', SongController.multerConfig)) - @ApiOperation({ - summary: 'Upload a .nbs file and send the song data, creating a new song', - }) - public async createSong( - @UploadedFile() file: Express.Multer.File, - @Body() body: UploadSongDto, - @GetRequestToken() user: UserDocument | null, - ): Promise { + @ApiOperation({ summary: 'Upload a .nbs file and send the song data, creating a new song', }) + public async createSong(@UploadedFile() file: Express.Multer.File, @Body() body: UploadSongDto, @GetRequestToken() user: UserDocument | null,): Promise { user = validateUser(user); return await this.songService.uploadSong({ body, file, user }); } diff --git a/apps/backend/src/song/song.module.ts b/apps/backend/src/song/song.module.ts index 52218af7..01b8a389 100644 --- a/apps/backend/src/song/song.module.ts +++ b/apps/backend/src/song/song.module.ts @@ -25,13 +25,13 @@ import { SongService } from './song.service'; SongUploadService, SongWebhookService, { - inject: [ConfigService], - provide: 'DISCORD_WEBHOOK_URL', + inject : [ConfigService], + provide : 'DISCORD_WEBHOOK_URL', useFactory: (configService: ConfigService) => configService.getOrThrow('DISCORD_WEBHOOK_URL'), }, ], controllers: [SongController, MySongsController], - exports: [SongService], + exports : [SongService], }) export class SongModule {} diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index b5445461..65e15eda 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -22,21 +22,21 @@ import { SongWebhookService } from './song-webhook/song-webhook.service'; import { SongService } from './song.service'; const mockFileService = { - deleteSong: jest.fn(), + deleteSong : jest.fn(), getSongDownloadUrl: jest.fn(), }; const mockSongUploadService = { processUploadedSong: jest.fn(), - processSongPatch: jest.fn(), + processSongPatch : jest.fn(), }; const mockSongWebhookService = { syncAllSongsWebhook: jest.fn(), - postSongWebhook: jest.fn(), - updateSongWebhook: jest.fn(), - deleteSongWebhook: jest.fn(), - syncSongWebhook: jest.fn(), + postSongWebhook : jest.fn(), + updateSongWebhook : jest.fn(), + deleteSongWebhook : jest.fn(), + syncSongWebhook : jest.fn(), }; describe('SongService', () => { @@ -50,19 +50,19 @@ describe('SongService', () => { providers: [ SongService, { - provide: SongWebhookService, + provide : SongWebhookService, useValue: mockSongWebhookService, }, { - provide: getModelToken(SongEntity.name), + provide : getModelToken(SongEntity.name), useValue: mongoose.model(SongEntity.name, SongSchema), }, { - provide: FileService, + provide : FileService, useValue: mockFileService, }, { - provide: SongUploadService, + provide : SongUploadService, useValue: mockSongUploadService, }, ], @@ -84,53 +84,53 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - file: 'somebytes', + file : 'somebytes', }; const commonData = { - publicId: 'public-song-id', + publicId : 'public-song-id', createdAt: new Date(), - stats: { - midiFileName: 'test.mid', - noteCount: 100, - tickCount: 1000, - layerCount: 10, - tempo: 120, - tempoRange: [100, 150], - timeSignature: 4, - duration: 60, - loop: true, - loopStartTick: 0, - minutesSpent: 10, - vanillaInstrumentCount: 10, - customInstrumentCount: 0, + stats : { + midiFileName : 'test.mid', + noteCount : 100, + tickCount : 1000, + layerCount : 10, + tempo : 120, + tempoRange : [100, 150], + timeSignature : 4, + duration : 60, + loop : true, + loopStartTick : 0, + minutesSpent : 10, + vanillaInstrumentCount : 10, + customInstrumentCount : 0, firstCustomInstrumentIndex: 0, - outOfRangeNoteCount: 0, - detunedNoteCount: 0, - customInstrumentNoteCount: 0, - incompatibleNoteCount: 0, - compatible: true, - instrumentNoteCounts: [10], + outOfRangeNoteCount : 0, + detunedNoteCount : 0, + customInstrumentNoteCount : 0, + incompatibleNoteCount : 0, + compatible : true, + instrumentNoteCounts : [10], }, - fileSize: 424242, + fileSize : 424242, packedSongUrl: 'http://test.com/packed-file.nbs', - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', - uploader: user._id, + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', + uploader : user._id, }; const songEntity = new SongEntity(); @@ -189,29 +189,29 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songEntity = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - file: 'somebytes', - publicId: 'public-song-id', - createdAt: new Date(), - stats: {} as SongStats, - fileSize: 424242, + file : 'somebytes', + publicId : 'public-song-id', + createdAt : new Date(), + stats : {} as SongStats, + fileSize : 424242, packedSongUrl: 'http://test.com/packed-file.nbs', - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', - uploader: user._id, + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', + uploader : user._id, } as unknown as SongEntity; const populatedSong = { @@ -254,7 +254,7 @@ describe('SongService', () => { const mockFindOne = { findOne: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(null), + exec : jest.fn().mockResolvedValue(null), }; jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); @@ -305,53 +305,53 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - file: 'somebytes', + file : 'somebytes', }; const missingData = { - publicId: 'public-song-id', + publicId : 'public-song-id', createdAt: new Date(), - stats: { - midiFileName: 'test.mid', - noteCount: 100, - tickCount: 1000, - layerCount: 10, - tempo: 120, - tempoRange: [100, 150], - timeSignature: 4, - duration: 60, - loop: true, - loopStartTick: 0, - minutesSpent: 10, - vanillaInstrumentCount: 10, - customInstrumentCount: 0, + stats : { + midiFileName : 'test.mid', + noteCount : 100, + tickCount : 1000, + layerCount : 10, + tempo : 120, + tempoRange : [100, 150], + timeSignature : 4, + duration : 60, + loop : true, + loopStartTick : 0, + minutesSpent : 10, + vanillaInstrumentCount : 10, + customInstrumentCount : 0, firstCustomInstrumentIndex: 0, - outOfRangeNoteCount: 0, - detunedNoteCount: 0, - customInstrumentNoteCount: 0, - incompatibleNoteCount: 0, - compatible: true, - instrumentNoteCounts: [10], + outOfRangeNoteCount : 0, + detunedNoteCount : 0, + customInstrumentNoteCount : 0, + incompatibleNoteCount : 0, + compatible : true, + instrumentNoteCounts : [10], }, - fileSize: 424242, + fileSize : 424242, packedSongUrl: 'http://test.com/packed-file.nbs', - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', - uploader: user._id, + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', + uploader : user._id, }; const songDocument: SongDocument = { @@ -403,20 +403,20 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, - file: 'somebytes', + file : 'somebytes', allowDownload: false, }; @@ -432,20 +432,20 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, - file: 'somebytes', + file : 'somebytes', allowDownload: false, }; @@ -465,20 +465,20 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, - file: 'somebytes', + file : 'somebytes', allowDownload: false, }; @@ -498,39 +498,39 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const body: UploadSongDto = { - file: undefined, - allowDownload: false, - visibility: 'public', - title: '', + file : undefined, + allowDownload : false, + visibility : 'public', + title : '', originalAuthor: '', - description: '', - category: 'pop', - thumbnailData: { + description : '', + category : 'pop', + thumbnailData : { backgroundColor: '#000000', - startLayer: 0, - startTick: 0, - zoomLevel: 1, + startLayer : 0, + startTick : 0, + zoomLevel : 1, }, - license: 'standard', + license : 'standard', customInstruments: [], }; const songEntity = { - uploader: user._id, - file: undefined, - allowDownload: false, - visibility: 'public', - title: '', + uploader : user._id, + file : undefined, + allowDownload : false, + visibility : 'public', + title : '', originalAuthor: '', - description: '', - category: 'pop', - thumbnailData: { + description : '', + category : 'pop', + thumbnailData : { backgroundColor: '#000000', - startLayer: 0, - startTick: 0, - zoomLevel: 1, + startLayer : 0, + startTick : 0, + zoomLevel : 1, }, - license: 'standard', + license : 'standard', customInstruments: [], } as any; @@ -545,20 +545,20 @@ describe('SongService', () => { describe('getSongByPage', () => { it('should return a list of songs by page', async () => { const query = { - page: 1, + page : 1, limit: 10, - sort: 'createdAt', + sort : 'createdAt', order: true, }; const songList: SongWithUser[] = []; const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), + skip : jest.fn().mockReturnThis(), + limit : jest.fn().mockReturnThis(), populate: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(songList), + exec : jest.fn().mockResolvedValue(songList), }; jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); @@ -582,20 +582,20 @@ describe('SongService', () => { it('should throw an error if the query is invalid', async () => { const query = { - page: undefined, + page : undefined, limit: undefined, - sort: undefined, + sort : undefined, order: true, }; const songList: SongWithUser[] = []; const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), + skip : jest.fn().mockReturnThis(), + limit : jest.fn().mockReturnThis(), populate: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(songList), + exec : jest.fn().mockResolvedValue(songList), }; jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); @@ -610,23 +610,23 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songDocument = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, - file: 'somebytes', + file : 'somebytes', allowDownload: false, - uploader: {}, - save: jest.fn(), + uploader : {}, + save : jest.fn(), } as any; songDocument.save = jest.fn().mockResolvedValue(songDocument); @@ -665,9 +665,9 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songEntity = { - publicId: 'test-public-id', + publicId : 'test-public-id', visibility: 'private', - uploader: 'different-user-id', + uploader : 'different-user-id', }; jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); @@ -680,9 +680,9 @@ describe('SongService', () => { const user: UserDocument = null as any; const songEntity = { - publicId: 'test-public-id', + publicId : 'test-public-id', visibility: 'private', - uploader: 'different-user-id', + uploader : 'different-user-id', }; jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); @@ -696,29 +696,29 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songEntity = { - visibility: 'public', - uploader: 'test-user-id', - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - license: 'standard', + visibility : 'public', + uploader : 'test-user-id', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - publicId: 'public-song-id', - createdAt: new Date(), - stats: {} as SongStats, - fileSize: 424242, + publicId : 'public-song-id', + createdAt : new Date(), + stats : {} as SongStats, + fileSize : 424242, packedSongUrl: 'http://test.com/packed-file.nbs', - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', - save: jest.fn(), + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', + save : jest.fn(), }; const url = 'http://test.com/song.nbs'; @@ -754,7 +754,7 @@ describe('SongService', () => { const songEntity = { visibility: 'private', - uploader: 'different-user-id', + uploader : 'different-user-id', }; jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); @@ -769,29 +769,29 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songEntity = { - visibility: 'public', - uploader: 'test-user-id', - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - license: 'standard', + visibility : 'public', + uploader : 'test-user-id', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: false, - publicId: 'public-song-id', - createdAt: new Date(), - stats: {} as SongStats, - fileSize: 424242, + publicId : 'public-song-id', + createdAt : new Date(), + stats : {} as SongStats, + fileSize : 424242, packedSongUrl: undefined, - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', - save: jest.fn(), + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', + save : jest.fn(), }; jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); @@ -821,29 +821,29 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songEntity = { - visibility: 'public', - uploader: 'test-user-id', - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - license: 'standard', + visibility : 'public', + uploader : 'test-user-id', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - publicId: 'public-song-id', - createdAt: new Date(), - stats: {} as SongStats, - fileSize: 424242, + publicId : 'public-song-id', + createdAt : new Date(), + stats : {} as SongStats, + fileSize : 424242, packedSongUrl: 'http://test.com/packed-file.nbs', - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', - save: jest.fn().mockImplementationOnce(() => { + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', + save : jest.fn().mockImplementationOnce(() => { throw new Error('Error saving song'); }), }; @@ -863,9 +863,9 @@ describe('SongService', () => { describe('getMySongsPage', () => { it('should return a list of songs uploaded by the user', async () => { const query = { - page: 1, + page : 1, limit: 10, - sort: 'createdAt', + sort : 'createdAt', order: true, }; @@ -873,8 +873,8 @@ describe('SongService', () => { const songList: SongWithUser[] = []; const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), + skip : jest.fn().mockReturnThis(), limit: jest.fn().mockResolvedValue(songList), }; @@ -887,7 +887,7 @@ describe('SongService', () => { content: songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song), ), - page: 1, + page : 1, limit: 10, total: 0, }); @@ -915,7 +915,7 @@ describe('SongService', () => { songEntity.uploader = user._id; // Ensure uploader is set const mockFindOne = { - exec: jest.fn().mockResolvedValue(songEntity), + exec : jest.fn().mockResolvedValue(songEntity), populate: jest.fn().mockReturnThis(), }; @@ -934,7 +934,7 @@ describe('SongService', () => { const findOneMock = { findOne: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(null), + exec : jest.fn().mockResolvedValue(null), }; jest.spyOn(songModel, 'findOne').mockReturnValue(findOneMock as any); @@ -949,33 +949,33 @@ describe('SongService', () => { const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songEntity = { - uploader: 'different-user-id', - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', + uploader : 'different-user-id', + title : 'Test Song', + originalAuthor : 'Test Author', + description : 'Test Description', + category : 'alternative', + visibility : 'public', + license : 'standard', customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, + thumbnailData : { + startTick : 0, + startLayer : 0, + zoomLevel : 1, backgroundColor: '#000000', }, allowDownload: true, - publicId: 'public-song-id', - createdAt: new Date(), - stats: {} as SongStats, - fileSize: 424242, + publicId : 'public-song-id', + createdAt : new Date(), + stats : {} as SongStats, + fileSize : 424242, packedSongUrl: 'http://test.com/packed-file.nbs', - nbsFileUrl: 'http://test.com/file.nbs', - thumbnailUrl: 'http://test.com/thumbnail.nbs', + nbsFileUrl : 'http://test.com/file.nbs', + thumbnailUrl : 'http://test.com/thumbnail.nbs', } as unknown as SongEntity; const findOneMock = { findOne: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(songEntity), + exec : jest.fn().mockResolvedValue(songEntity), }; jest.spyOn(songModel, 'findOne').mockReturnValue(findOneMock as any); @@ -1015,11 +1015,11 @@ describe('SongService', () => { const songList: SongWithUser[] = []; const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), + skip : jest.fn().mockReturnThis(), + limit : jest.fn().mockReturnThis(), populate: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(songList), + exec : jest.fn().mockResolvedValue(songList), }; jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); @@ -1047,4 +1047,70 @@ describe('SongService', () => { expect(mockFind.exec).toHaveBeenCalled(); }); }); + + describe('getFeaturedSongs', () => { + it('should return featured songs', async () => { + const songWithUser: SongWithUser = { + title : 'Test Song', + publicId : 'test-id', + uploader : { username: 'testuser', profileImage: 'testimage' }, + description : 'Test Description', + originalAuthor: 'Test Author', + stats : { + duration : 100, + noteCount: 100, + }, + thumbnailUrl: 'test-thumbnail-url', + createdAt : new Date(), + updatedAt : new Date(), + playCount : 0, + visibility : 'public', + } as any; + + jest + .spyOn(service, 'getSongsForTimespan') + .mockResolvedValue([songWithUser]); + + jest + .spyOn(service, 'getSongsBeforeTimespan') + .mockResolvedValue([]); + + const result = await service.getFeaturedSongs(); + + expect(service.getSongsForTimespan).toHaveBeenCalledTimes(6); // Called for each timespan + expect(result).toBeInstanceOf(Object); + expect(result).toHaveProperty('hour'); + expect(result).toHaveProperty('day'); + expect(result).toHaveProperty('week'); + expect(result).toHaveProperty('month'); + expect(result).toHaveProperty('year'); + expect(result).toHaveProperty('all'); + expect(Array.isArray(result.hour)).toBe(true); + expect(Array.isArray(result.day)).toBe(true); + expect(Array.isArray(result.week)).toBe(true); + expect(Array.isArray(result.month)).toBe(true); + expect(Array.isArray(result.year)).toBe(true); + expect(Array.isArray(result.all)).toBe(true); + }); + + it('should handle empty results gracefully', async () => { + jest + .spyOn(service, 'getSongsForTimespan') + .mockResolvedValue([]); + + jest + .spyOn(service, 'getSongsBeforeTimespan') + .mockResolvedValue([]); + + const result = await service.getFeaturedSongs(); + + expect(result).toBeInstanceOf(Object); + expect(result.hour).toEqual([]); + expect(result.day).toEqual([]); + expect(result.week).toEqual([]); + expect(result.month).toEqual([]); + expect(result.year).toEqual([]); + expect(result.all).toEqual([]); + }); + }); }); diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index 1da06c08..89b20128 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -1,22 +1,6 @@ import { BROWSER_SONGS } from '@nbw/config'; -import type { UserDocument } from '@nbw/database'; -import { - PageQueryDTO, - Song as SongEntity, - SongPageDto, - SongPreviewDto, - SongViewDto, - SongWithUser, - UploadSongDto, - UploadSongResponseDto, -} from '@nbw/database'; -import { - HttpException, - HttpStatus, - Inject, - Injectable, - Logger, -} from '@nestjs/common'; +import { FeaturedSongsDto, TimespanType, UserDocument, PageQueryDTO, Song as SongEntity, SongPageDto, SongPreviewDto, SongViewDto, SongWithUser, UploadSongDto, UploadSongResponseDto, } from '@nbw/database'; +import { HttpException, HttpStatus, Inject, Injectable, Logger, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; @@ -49,15 +33,7 @@ export class SongService { }); } - public async uploadSong({ - file, - user, - body, - }: { - body: UploadSongDto; - file: Express.Multer.File; - user: UserDocument; - }): Promise { + public async uploadSong({ file, user, body, }: { body: UploadSongDto; file: Express.Multer.File; user: UserDocument;}): Promise { const song = await this.songUploadService.processUploadedSong({ file, user, @@ -85,10 +61,7 @@ export class SongService { return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); } - public async deleteSong( - publicId: string, - user: UserDocument, - ): Promise { + public async deleteSong(publicId: string, user: UserDocument,): Promise { const foundSong = await this.songModel .findOne({ publicId: publicId }) .exec(); @@ -115,11 +88,7 @@ export class SongService { return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); } - public async patchSong( - publicId: string, - body: UploadSongDto, - user: UserDocument, - ): Promise { + public async patchSong(publicId: string, body: UploadSongDto, user: UserDocument,): Promise { const foundSong = await this.songModel.findOne({ publicId: publicId, }); @@ -202,16 +171,52 @@ export class SongService { }) .skip(page * limit - limit) .limit(limit) + .populate('uploader', 'username publicName profileImage -_id') + .exec()) as unknown as SongWithUser[]; + + return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + } + + public async searchSongs(query: PageQueryDTO, q: string,): Promise { + const page = parseInt(query.page?.toString() ?? '1'); + const limit = parseInt(query.limit?.toString() ?? '10'); + const order = query.order ? query.order : false; + const allowedSorts = new Set(['likeCount', 'createdAt', 'playCount']); + const sortField = allowedSorts.has(query.sort ?? '') + ? (query.sort as string) + : 'createdAt'; + + const terms = (q || '') + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + // Build Google-like search: all words must appear across any of the fields + const andClauses = terms.map((word) => ({ + $or: [ + { title: { $regex: word, $options: 'i' } }, + { originalAuthor: { $regex: word, $options: 'i' } }, + { description: { $regex: word, $options: 'i' } }, + ], + })); + + const mongoQuery: any = { + visibility: 'public', + ...(andClauses.length > 0 ? { $and: andClauses } : {}), + }; + + const songs = (await this.songModel + .find(mongoQuery) + .sort({ [sortField]: order ? 1 : -1 }) + .skip(limit * (page - 1)) + .limit(limit) .populate('uploader', 'username profileImage -_id') .exec()) as unknown as SongWithUser[]; return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } - public async getRecentSongs( - page: number, - limit: number, - ): Promise { + public async getRecentSongs(page: number, limit: number,): Promise { const queryObject: any = { visibility: 'public', }; @@ -233,7 +238,7 @@ export class SongService { return this.songModel .find({ visibility: 'public', - createdAt: { + createdAt : { $gte: timespan, }, }) @@ -243,13 +248,11 @@ export class SongService { .exec(); } - public async getSongsBeforeTimespan( - timespan: number, - ): Promise { + public async getSongsBeforeTimespan(timespan: number,): Promise { return this.songModel .find({ visibility: 'public', - createdAt: { + createdAt : { $lt: timespan, }, }) @@ -259,10 +262,7 @@ export class SongService { .exec(); } - public async getSong( - publicId: string, - user: UserDocument | null, - ): Promise { + public async getSong(publicId: string, user: UserDocument | null,): Promise { const foundSong = await this.songModel.findOne({ publicId: publicId }); if (!foundSong) { @@ -292,12 +292,7 @@ export class SongService { } // TODO: service should not handle HTTP -> https://www.reddit.com/r/node/comments/uoicw1/should_i_return_status_code_from_service_layer/ - public async getSongDownloadUrl( - publicId: string, - user: UserDocument | null, - src?: string, - packed: boolean = false, - ): Promise { + public async getSongDownloadUrl(publicId: string, user: UserDocument | null, src?: string, packed: boolean = false,): Promise { const foundSong = await this.songModel.findOne({ publicId: publicId }); if (!foundSong) { @@ -342,13 +337,7 @@ export class SongService { } } - public async getMySongsPage({ - query, - user, - }: { - query: PageQueryDTO; - user: UserDocument; - }): Promise { + public async getMySongsPage({ query, user, }: { query: PageQueryDTO; user: UserDocument;}): Promise { const page = parseInt(query.page?.toString() ?? '1'); const limit = parseInt(query.limit?.toString() ?? '10'); const order = query.order ? query.order : false; @@ -372,16 +361,13 @@ export class SongService { content: songData.map((song) => SongPreviewDto.fromSongDocumentWithUser(song), ), - page: page, + page : page, limit: limit, total: total, }; } - public async getSongEdit( - publicId: string, - user: UserDocument, - ): Promise { + public async getSongEdit(publicId: string, user: UserDocument,): Promise { const foundSong = await this.songModel .findOne({ publicId: publicId }) .exec(); @@ -408,7 +394,7 @@ export class SongService { }, { $group: { - _id: '$category', + _id : '$category', count: { $sum: 1 }, }, }, @@ -429,14 +415,10 @@ export class SongService { }, {} as Record); } - public async getSongsByCategory( - category: string, - page: number, - limit: number, - ): Promise { + public async getSongsByCategory(category: string, page: number, limit: number,): Promise { const songs = (await this.songModel .find({ - category: category, + category : category, visibility: 'public', }) .sort({ createdAt: -1 }) @@ -448,15 +430,13 @@ export class SongService { return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } - public async getRandomSongs( - count: number, - category: string, - ): Promise { + public async getRandomSongs(count: number, category: string,): Promise { const songs = (await this.songModel .aggregate([ { $match: { visibility: 'public', + category : category, }, }, { @@ -468,14 +448,58 @@ export class SongService { .exec()) as unknown as SongWithUser[]; await this.songModel.populate(songs, { - path: 'uploader', + path : 'uploader', select: 'username profileImage -_id', }); return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } - public async getAllSongs() { - return this.songModel.find({}); + public async getFeaturedSongs(): Promise { + const now = new Date(Date.now()); + + const times: Record = { + hour : new Date(Date.now()).setHours(now.getHours() - 1), + day : new Date(Date.now()).setDate(now.getDate() - 1), + week : new Date(Date.now()).setDate(now.getDate() - 7), + month: new Date(Date.now()).setMonth(now.getMonth() - 1), + year : new Date(Date.now()).setFullYear(now.getFullYear() - 1), + all : new Date(0).getTime(), + }; + + const songs: Record = { hour: [], day: [], week: [], month: [], year: [], all: [], }; + + for (const [timespan, time] of Object.entries(times)) { + const songPage = await this.getSongsForTimespan(time); + + // If the length is 0, send an empty array (no songs available in that timespan) + // If the length is less than the page size, pad it with songs "borrowed" + // from the nearest timestamp, regardless of view count + if ( + songPage.length > 0 && + songPage.length < BROWSER_SONGS.paddedFeaturedPageSize + ) { + const missing = BROWSER_SONGS.paddedFeaturedPageSize - songPage.length; + + const additionalSongs = await this.getSongsBeforeTimespan( + time, + ); + + songPage.push(...additionalSongs.slice(0, missing)); + } + + songs[timespan as TimespanType] = songPage; + } + + const featuredSongs = FeaturedSongsDto.create(); + + featuredSongs.hour = songs.hour.map((song) => SongPreviewDto.fromSongDocumentWithUser(song),); + featuredSongs.day = songs.day.map((song) => SongPreviewDto.fromSongDocumentWithUser(song),); + featuredSongs.week = songs.week.map((song) => SongPreviewDto.fromSongDocumentWithUser(song),); + featuredSongs.month = songs.month.map((song) => SongPreviewDto.fromSongDocumentWithUser(song),); + featuredSongs.year = songs.year.map((song) => SongPreviewDto.fromSongDocumentWithUser(song),); + featuredSongs.all = songs.all.map((song) => SongPreviewDto.fromSongDocumentWithUser(song),); + + return featuredSongs; } } diff --git a/apps/backend/src/song/song.util.ts b/apps/backend/src/song/song.util.ts index 50168171..0b38e2a3 100644 --- a/apps/backend/src/song/song.util.ts +++ b/apps/backend/src/song/song.util.ts @@ -46,26 +46,26 @@ export function getUploadDiscordEmbed({ if (originalAuthor) { fieldsArray.push({ - name: 'Original Author', - value: originalAuthor, + name : 'Original Author', + value : originalAuthor, inline: false, }); } fieldsArray = fieldsArray.concat([ { - name: 'Category', - value: UPLOAD_CONSTANTS.categories[category], + name : 'Category', + value : UPLOAD_CONSTANTS.categories[category], inline: true, }, { - name: 'Notes', - value: stats.noteCount.toLocaleString('en-US'), + name : 'Notes', + value : stats.noteCount.toLocaleString('en-US'), inline: true, }, { - name: 'Length', - value: formatDuration(stats.duration), + name : 'Length', + value : formatDuration(stats.duration), inline: true, }, ]); @@ -73,23 +73,23 @@ export function getUploadDiscordEmbed({ return { embeds: [ { - title: title, + title : title, description: description, - color: Number('0x' + thumbnailData.backgroundColor.replace('#', '')), - timestamp: createdAt.toISOString(), - footer: { + color : Number('0x' + thumbnailData.backgroundColor.replace('#', '')), + timestamp : createdAt.toISOString(), + footer : { text: UPLOAD_CONSTANTS.licenses[license] ? UPLOAD_CONSTANTS.licenses[license].shortName : 'Unknown License', }, author: { - name: uploader.username, + name : uploader.username, icon_url: uploader.profileImage, //url: 'https://noteblock.world/user/${uploaderName}', }, fields: fieldsArray, - url: `https://noteblock.world/song/${publicId}`, - image: { + url : `https://noteblock.world/song/${publicId}`, + image : { url: thumbnailUrl, }, thumbnail: { diff --git a/apps/backend/src/user/user.controller.spec.ts b/apps/backend/src/user/user.controller.spec.ts index b8b94b96..4ff82173 100644 --- a/apps/backend/src/user/user.controller.spec.ts +++ b/apps/backend/src/user/user.controller.spec.ts @@ -8,8 +8,8 @@ import { UserService } from './user.service'; const mockUserService = { getUserByEmailOrId: jest.fn(), - getUserPaginated: jest.fn(), - getSelfUserData: jest.fn(), + getUserPaginated : jest.fn(), + getSelfUserData : jest.fn(), }; describe('UserController', () => { @@ -19,9 +19,9 @@ describe('UserController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UserController], - providers: [ + providers : [ { - provide: UserService, + provide : UserService, useValue: mockUserService, }, ], @@ -38,9 +38,9 @@ describe('UserController', () => { describe('getUser', () => { it('should return user data by email or ID', async () => { const query: GetUser = { - email: 'test@email.com', + email : 'test@email.com', username: 'test-username', - id: 'test-id', + id : 'test-id', }; const user = { email: 'test@example.com' }; diff --git a/apps/backend/src/user/user.module.ts b/apps/backend/src/user/user.module.ts index 53a58c90..6c5cb045 100644 --- a/apps/backend/src/user/user.module.ts +++ b/apps/backend/src/user/user.module.ts @@ -9,8 +9,8 @@ import { UserService } from './user.service'; imports: [ MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), ], - providers: [UserService], + providers : [UserService], controllers: [UserController], - exports: [UserService], + exports : [UserService], }) export class UserModule {} diff --git a/apps/backend/src/user/user.service.spec.ts b/apps/backend/src/user/user.service.spec.ts index 56e07464..54e558c4 100644 --- a/apps/backend/src/user/user.service.spec.ts +++ b/apps/backend/src/user/user.service.spec.ts @@ -13,13 +13,13 @@ import { Model } from 'mongoose'; import { UserService } from './user.service'; const mockUserModel = { - create: jest.fn(), - findOne: jest.fn(), - findById: jest.fn(), - find: jest.fn(), - save: jest.fn(), - exec: jest.fn(), - select: jest.fn(), + create : jest.fn(), + findOne : jest.fn(), + findById : jest.fn(), + find : jest.fn(), + save : jest.fn(), + exec : jest.fn(), + select : jest.fn(), countDocuments: jest.fn(), }; @@ -32,7 +32,7 @@ describe('UserService', () => { providers: [ UserService, { - provide: getModelToken(User.name), + provide : getModelToken(User.name), useValue: mockUserModel, }, ], @@ -49,8 +49,8 @@ describe('UserService', () => { describe('create', () => { it('should create a new user', async () => { const createUserDto: CreateUser = { - username: 'testuser', - email: 'test@example.com', + username : 'testuser', + email : 'test@example.com', profileImage: 'testimage.png', }; @@ -109,13 +109,13 @@ describe('UserService', () => { const usersPage = { users, total: 1, - page: 1, + page : 1, limit: 10, }; const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), + skip : jest.fn().mockReturnThis(), limit: jest.fn().mockResolvedValue(users), }; @@ -184,7 +184,7 @@ describe('UserService', () => { jest.spyOn(userModel, 'findById').mockReturnValue({ populate: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(hydratedUser), + exec : jest.fn().mockResolvedValue(hydratedUser), } as any); const result = await service.getHydratedUser(user); @@ -229,9 +229,9 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: yesterday, + lastSeen : yesterday, loginStreak: 1, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -250,9 +250,9 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: today, + lastSeen : today, loginStreak: 1, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -272,9 +272,9 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: twoDaysAgo, + lastSeen : twoDaysAgo, loginStreak: 5, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -294,9 +294,9 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: yesterday, + lastSeen : yesterday, loginCount: 5, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -316,9 +316,9 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: today, + lastSeen : today, loginCount: 5, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -339,10 +339,10 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: yesterday, - loginStreak: 8, + lastSeen : yesterday, + loginStreak : 8, maxLoginStreak: 8, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -362,10 +362,10 @@ describe('UserService', () => { const userData = { ...user, - lastSeen: yesterday, - loginStreak: 4, + lastSeen : yesterday, + loginStreak : 4, maxLoginStreak: 8, - save: jest.fn().mockResolvedValue(true), + save : jest.fn().mockResolvedValue(true), } as unknown as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -384,7 +384,7 @@ describe('UserService', () => { jest.spyOn(userModel, 'findOne').mockReturnValue({ select: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(user), + exec : jest.fn().mockResolvedValue(user), } as any); const result = await service.usernameExists(username); @@ -402,7 +402,7 @@ describe('UserService', () => { jest.spyOn(userModel, 'findOne').mockReturnValue({ select: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(null), + exec : jest.fn().mockResolvedValue(null), } as any); const result = await service.usernameExists(username); @@ -488,7 +488,7 @@ describe('UserService', () => { it('should update a user username', async () => { const user = { username: 'testuser', - save: jest.fn().mockReturnThis(), + save : jest.fn().mockReturnThis(), } as unknown as UserDocument; const body = { username: 'newuser' }; @@ -498,9 +498,9 @@ describe('UserService', () => { const result = await service.updateUsername(user, body); expect(result).toEqual({ - username: 'newuser', + username : 'newuser', publicName: undefined, - email: undefined, + email : undefined, }); expect(user.username).toBe(body.username); @@ -510,7 +510,7 @@ describe('UserService', () => { it('should throw an error if username already exists', async () => { const user = { username: 'testuser', - save: jest.fn().mockReturnThis(), + save : jest.fn().mockReturnThis(), } as unknown as UserDocument; const body = { username: 'newuser' }; diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts index 051da77e..c83152ee 100644 --- a/apps/backend/src/user/user.service.ts +++ b/apps/backend/src/user/user.service.ts @@ -56,8 +56,8 @@ export class UserService { ); const user = await this.userModel.create({ - email: email, - username: emailPrefixUsername, + email : email, + username : emailPrefixUsername, publicName: emailPrefixUsername, }); diff --git a/apps/frontend/mdx-components.tsx b/apps/frontend/mdx-components.tsx index 92af1be8..b48713a4 100644 --- a/apps/frontend/mdx-components.tsx +++ b/apps/frontend/mdx-components.tsx @@ -1,40 +1,8 @@ import type { MDXComponents } from 'mdx/types'; -import { - a, - blockquote, - code, - h1, - h2, - h3, - h4, - h5, - h6, - hr, - li, - ol, - p, - pre, - ul, -} from '@web/modules/shared/components/CustomMarkdown'; +import { a, blockquote, code, h1, h2, h3, h4, h5, h6, hr, li, ol, p, pre, ul, } from '@web/modules/shared/components/CustomMarkdown'; + export function useMDXComponents(components: MDXComponents): MDXComponents { - return { - p, - h1, - h2, - h3, - h4, - h5, - h6, - hr, - ul, - ol, - li, - blockquote, - pre, - code, - a, - ...components, - }; + return { p, h1, h2, h3, h4, h5, h6, hr, ul, ol, li, blockquote, pre, code, a, ...components, }; } diff --git a/apps/frontend/src/app/(content)/(info)/about/page.tsx b/apps/frontend/src/app/(content)/(info)/about/page.tsx index 37916161..2190abd5 100644 --- a/apps/frontend/src/app/(content)/(info)/about/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/about/page.tsx @@ -1,3 +1,4 @@ + import type { Metadata } from 'next'; import BackButton from '@web/modules/shared/components/client/BackButton'; diff --git a/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx b/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx index c0d63d05..7df6c08b 100644 --- a/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx @@ -6,6 +6,7 @@ import { notFound } from 'next/navigation'; import { PostType, getPostData } from '@web/lib/posts'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; + type BlogPageProps = { params: { id: string }; }; @@ -17,13 +18,13 @@ export function generateMetadata({ params }: BlogPageProps): Metadata { const publicUrl = process.env.NEXT_PUBLIC_URL; return { - title: post.title, - authors: [{ name: post.author }], + title : post.title, + authors : [{ name: post.author }], openGraph: { - url: publicUrl + '/blog/' + id, - title: post.title, + url : publicUrl + '/blog/' + id, + title : post.title, siteName: 'Note Block World', - images: [ + images : [ { url: publicUrl + post.image, }, @@ -82,9 +83,9 @@ const BlogPost = ({ params }: BlogPageProps) => { {new Date( new Date(post.date).getTime() + 12 * 60 * 60 * 1000, ).toLocaleDateString('en-UK', { - day: 'numeric', + day : 'numeric', month: 'short', - year: 'numeric', + year : 'numeric', })}

diff --git a/apps/frontend/src/app/(content)/(info)/blog/page.tsx b/apps/frontend/src/app/(content)/(info)/blog/page.tsx index bcadb92c..23773d07 100644 --- a/apps/frontend/src/app/(content)/(info)/blog/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/blog/page.tsx @@ -4,8 +4,9 @@ import { Metadata } from 'next'; import Image from 'next/image'; import Link from 'next/link'; -import { getSortedPostsData } from '@web/lib/posts'; import type { PostType } from '@web/lib/posts'; +import { getSortedPostsData } from '@web/lib/posts'; + export const metadata: Metadata = { title: 'Blog', diff --git a/apps/frontend/src/app/(content)/(info)/contact/page.tsx b/apps/frontend/src/app/(content)/(info)/contact/page.tsx index ebd20a1a..d52ecdf5 100644 --- a/apps/frontend/src/app/(content)/(info)/contact/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/contact/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import BackButton from '@web/modules/shared/components/client/BackButton'; + import Contact from './contact.mdx'; export const metadata: Metadata = { diff --git a/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx b/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx index 2507590c..05d90e4f 100644 --- a/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx @@ -6,6 +6,7 @@ import { notFound } from 'next/navigation'; import { PostType, getPostData } from '@web/lib/posts'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; + type HelpPageProps = { params: { id: string }; }; @@ -17,13 +18,13 @@ export function generateMetadata({ params }: HelpPageProps): Metadata { const publicUrl = process.env.NEXT_PUBLIC_URL; return { - title: post.title, - authors: [{ name: post.author }], + title : post.title, + authors : [{ name: post.author }], openGraph: { - url: publicUrl + '/help/' + id, - title: post.title, + url : publicUrl + '/help/' + id, + title : post.title, siteName: 'Note Block World', - images: [ + images : [ { url: publicUrl + post.image, }, diff --git a/apps/frontend/src/app/(content)/(info)/help/page.tsx b/apps/frontend/src/app/(content)/(info)/help/page.tsx index f0984c24..8a57b647 100644 --- a/apps/frontend/src/app/(content)/(info)/help/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/help/page.tsx @@ -4,8 +4,9 @@ import { Metadata } from 'next'; import Image from 'next/image'; import Link from 'next/link'; -import { getSortedPostsData } from '@web/lib/posts'; import type { PostType } from '@web/lib/posts'; +import { getSortedPostsData } from '@web/lib/posts'; + export const metadata: Metadata = { title: 'Help Center', diff --git a/apps/frontend/src/app/(content)/my-songs/page.tsx b/apps/frontend/src/app/(content)/my-songs/page.tsx index 110df923..9fca9350 100644 --- a/apps/frontend/src/app/(content)/my-songs/page.tsx +++ b/apps/frontend/src/app/(content)/my-songs/page.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'; import { checkLogin } from '@web/modules/auth/features/auth.utils'; import Page from '@web/modules/my-songs/components/MySongsPage'; + export const metadata: Metadata = { title: 'My songs', }; diff --git a/apps/frontend/src/app/(content)/page.tsx b/apps/frontend/src/app/(content)/page.tsx index ac9ee47c..45b15807 100644 --- a/apps/frontend/src/app/(content)/page.tsx +++ b/apps/frontend/src/app/(content)/page.tsx @@ -8,12 +8,13 @@ import { HomePageComponent } from '@web/modules/browse/components/HomePageCompon async function fetchRecentSongs() { try { const response = await axiosInstance.get( - '/song-browser/recent', + '/song', { params: { - page: 1, // TODO: fiz constants + q : 'recent', + page : 1, // TODO: fiz constants limit: 16, // TODO: change 'limit' parameter to 'skip' and load 12 songs initially, then load 8 more songs on each pagination - sort: 'recent', + sort : 'recent', order: false, }, }, @@ -28,18 +29,18 @@ async function fetchRecentSongs() { async function fetchFeaturedSongs(): Promise { try { const response = await axiosInstance.get( - '/song-browser/featured', + '/song?q=featured', ); return response.data; } catch (error) { return { - hour: [], - day: [], - week: [], + hour : [], + day : [], + week : [], month: [], - year: [], - all: [], + year : [], + all : [], }; } } diff --git a/apps/frontend/src/app/(content)/search-song/page.tsx b/apps/frontend/src/app/(content)/search-song/page.tsx new file mode 100644 index 00000000..93e1c9a5 --- /dev/null +++ b/apps/frontend/src/app/(content)/search-song/page.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { + faEllipsis, + faMagnifyingGlass, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { SongPreviewDtoType } from '@nbw/database'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import LoadMoreButton from '@web/modules/browse/components/client/LoadMoreButton'; +import SongCard from '@web/modules/browse/components/SongCard'; +import SongCardGroup from '@web/modules/browse/components/SongCardGroup'; + +// Mock data for testing +const mockSongs: SongPreviewDtoType[] = [ + { + publicId: '1', + uploader: { + username : 'musicmaker', + profileImage: '/img/note-block-pfp.jpg', + }, + title : 'Beautiful Melody', + description : 'A peaceful song for relaxation', + originalAuthor: 'John Doe', + duration : 180, + noteCount : 150, + thumbnailUrl : '/img/note-block-grayscale.png', + createdAt : new Date('2024-01-15'), + updatedAt : new Date('2024-01-15'), + playCount : 1245, + visibility : 'public', + }, + { + publicId: '2', + uploader: { + username : 'composer123', + profileImage: '/img/note-block-pfp.jpg', + }, + title : 'Epic Adventure Theme', + description : 'An exciting soundtrack for your adventures', + originalAuthor: 'Jane Smith', + duration : 240, + noteCount : 320, + thumbnailUrl : '/img/note-block-grayscale.png', + createdAt : new Date('2024-01-14'), + updatedAt : new Date('2024-01-14'), + playCount : 856, + visibility : 'public', + }, + { + publicId: '3', + uploader: { + username : 'beatmaster', + profileImage: '/img/note-block-pfp.jpg', + }, + title : 'Minecraft Nostalgia', + description : 'Classic minecraft-inspired music', + originalAuthor: 'C418', + duration : 195, + noteCount : 280, + thumbnailUrl : '/img/note-block-grayscale.png', + createdAt : new Date('2024-01-13'), + updatedAt : new Date('2024-01-13'), + playCount : 2134, + visibility : 'public', + }, +]; + +const SearchSongPage = () => { + const searchParams = useSearchParams(); + const query = searchParams.get('q') || ''; + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '20'); + + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [currentPage, setCurrentPage] = useState(page); + + // Mock search function + const searchSongs = (searchQuery: string, pageNum: number) => { + setLoading(true); + + // Simulate API call delay + setTimeout(() => { + if (pageNum === 1) { + // Filter mock songs based on query + const filtered = mockSongs.filter( + (song) => + song.title.toLowerCase().includes(searchQuery.toLowerCase()) || + song.description + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + song.originalAuthor + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + song.uploader.username + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ); + setSongs(filtered); + setHasMore(filtered.length >= limit); + } else { + // For pagination, just add duplicates with modified IDs for demo + const additionalSongs = mockSongs.slice(0, 2).map((song, index) => ({ + ...song, + publicId: `${song.publicId}-page-${pageNum}-${index}`, + title : `${song.title} (Page ${pageNum})`, + })); + setSongs((prev) => [...prev, ...additionalSongs]); + setHasMore(pageNum < 3); // Mock: only show 3 pages max + } + setLoading(false); + }, 500); + }; + + const loadMore = () => { + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + searchSongs(query, nextPage); + }; + + useEffect(() => { + setCurrentPage(page); + searchSongs(query, page); + }, [query, page]); + + if (loading && songs.length === 0) { + return ( +
+
+ +

Searching...

+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + +
+ ); + } + + return ( +
+ {/* Search header */} +
+ +
+

Search Results

+ {query && ( +

+ {songs.length > 0 + ? `Found ${songs.length} songs for "${query}"` + : `No songs found for "${query}"`} +

+ )} +
+
+ + {/* Results */} + {songs.length > 0 ? ( + <> + + {songs.map((song, i) => ( + + ))} + + + {/* Load more / End indicator */} +
+ {loading ? ( +
Loading more songs...
+ ) : hasMore ? ( + + ) : ( + + )} +
+ + ) : !loading ? ( +
+ +

No songs found

+

+ Try adjusting your search terms or browse our featured songs + instead. +

+
+ ) : null} +
+ ); +}; + +export default SearchSongPage; diff --git a/apps/frontend/src/app/(content)/song/[id]/edit/page.tsx b/apps/frontend/src/app/(content)/song/[id]/edit/page.tsx index 8af0f5de..ade9be98 100644 --- a/apps/frontend/src/app/(content)/song/[id]/edit/page.tsx +++ b/apps/frontend/src/app/(content)/song/[id]/edit/page.tsx @@ -1,3 +1,4 @@ + import { redirect } from 'next/navigation'; import { checkLogin } from '@web/modules/auth/features/auth.utils'; diff --git a/apps/frontend/src/app/(content)/song/[id]/page.tsx b/apps/frontend/src/app/(content)/song/[id]/page.tsx index 198a9c96..a4057eb5 100644 --- a/apps/frontend/src/app/(content)/song/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/song/[id]/page.tsx @@ -5,6 +5,7 @@ import { cookies } from 'next/headers'; import axios from '@web/lib/axios'; import { SongPage } from '@web/modules/song/components/SongPage'; + interface SongPage { params: { id: string; @@ -39,15 +40,15 @@ export async function generateMetadata({ } return { - title: song.title, + title : song.title, description: song.description, - authors: [{ name: song.uploader.username }], - openGraph: { - url: publicUrl + '/song/' + song.publicId, - title: song.title, + authors : [{ name: song.uploader.username }], + openGraph : { + url : publicUrl + '/song/' + song.publicId, + title : song.title, description: song.description, - siteName: 'Note Block World', - images: [song.thumbnailUrl], + siteName : 'Note Block World', + images : [song.thumbnailUrl], }, twitter: { card: 'summary_large_image', diff --git a/apps/frontend/src/app/(content)/upload/page.tsx b/apps/frontend/src/app/(content)/upload/page.tsx index bccf0c9d..18467ef8 100644 --- a/apps/frontend/src/app/(content)/upload/page.tsx +++ b/apps/frontend/src/app/(content)/upload/page.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'; import { checkLogin, getUserData } from '@web/modules/auth/features/auth.utils'; import { UploadSongPage } from '@web/modules/song-upload/components/client/UploadSongPage'; + export const metadata: Metadata = { title: 'Upload song', }; diff --git a/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx b/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx index be8b80b9..0cd59aee 100644 --- a/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx +++ b/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'; import { LoginWithEmailPage } from '@web/modules/auth/components/loginWithEmailPage'; import { checkLogin } from '@web/modules/auth/features/auth.utils'; + export const metadata: Metadata = { title: 'Sign in', }; diff --git a/apps/frontend/src/app/(external)/(auth)/login/page.tsx b/apps/frontend/src/app/(external)/(auth)/login/page.tsx index b8c1de65..e3a3e4e8 100644 --- a/apps/frontend/src/app/(external)/(auth)/login/page.tsx +++ b/apps/frontend/src/app/(external)/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'; import { LoginPage } from '@web/modules/auth/components/loginPage'; import { checkLogin } from '@web/modules/auth/features/auth.utils'; + export const metadata: Metadata = { title: 'Sign in', }; diff --git a/apps/frontend/src/app/(external)/(auth)/logout/page.tsx b/apps/frontend/src/app/(external)/(auth)/logout/page.tsx index f69f55f3..d438877a 100644 --- a/apps/frontend/src/app/(external)/(auth)/logout/page.tsx +++ b/apps/frontend/src/app/(external)/(auth)/logout/page.tsx @@ -4,6 +4,7 @@ import { toast } from 'react-hot-toast'; import { useSignOut } from '@web/modules/auth/components/client/login.util'; + function Page() { useSignOut(); toast.success('You have been logged out!'); diff --git a/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx b/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx index 5ba331fc..c78dabda 100644 --- a/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx +++ b/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx @@ -5,6 +5,7 @@ import { Metadata } from 'next'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; + export const metadata: Metadata = { title: 'Community Guidelines', }; diff --git a/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx b/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx index 4ef11c59..82228df3 100644 --- a/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx +++ b/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx @@ -5,6 +5,7 @@ import { Metadata } from 'next'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; + export const metadata: Metadata = { title: 'Privacy Policy', }; diff --git a/apps/frontend/src/app/(external)/(legal)/terms/page.tsx b/apps/frontend/src/app/(external)/(legal)/terms/page.tsx index 40290108..b8d2ed3b 100644 --- a/apps/frontend/src/app/(external)/(legal)/terms/page.tsx +++ b/apps/frontend/src/app/(external)/(legal)/terms/page.tsx @@ -5,6 +5,7 @@ import { Metadata } from 'next'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; + export const metadata: Metadata = { title: 'Terms of Service', }; diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 76ddeaff..9b2b3d97 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -15,7 +15,7 @@ import { TooltipProvider } from '../modules/shared/components/tooltip'; const lato = Lato({ subsets: ['latin'], - weight: ['100', '300', '400', '700', '900'], + weight : ['100', '300', '400', '700', '900'], }); export const metadata: Metadata = { @@ -23,21 +23,21 @@ export const metadata: Metadata = { description: 'Discover, share and listen to note block music from all around the world', applicationName: 'Note Block World', - keywords: ['note block', 'music', 'minecraft', 'nbs', 'note block studio'], - openGraph: { - type: 'website', - images: `${process.env.NEXT_PUBLIC_URL}/nbw-header.png`, - locale: 'en_US', - url: process.env.NEXT_PUBLIC_URL, + keywords : ['note block', 'music', 'minecraft', 'nbs', 'note block studio'], + openGraph : { + type : 'website', + images : `${process.env.NEXT_PUBLIC_URL}/nbw-header.png`, + locale : 'en_US', + url : process.env.NEXT_PUBLIC_URL, siteName: 'Note Block World', }, }; const jsonLd: WithContext = { '@context': 'https://schema.org', - '@type': 'WebSite', - name: 'Note Block World', - url: process.env.NEXT_PUBLIC_URL, + '@type' : 'WebSite', + name : 'Note Block World', + url : process.env.NEXT_PUBLIC_URL, description: 'Discover, share and listen to note block music from all around the world', }; @@ -100,7 +100,7 @@ export default function RootLayout({ position='bottom-center' toastOptions={{ className: '!bg-zinc-700 !text-white !max-w-fit', - duration: 4000, + duration : 4000, }} /> ), content: matterResult.content, }; diff --git a/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx b/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx index ab4a701c..1b9e89a4 100644 --- a/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx +++ b/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx @@ -8,6 +8,7 @@ import { toast } from 'react-hot-toast'; import ClientAxios from '@web/lib/axios/ClientAxios'; + import { Input, SubmitButton, diff --git a/apps/frontend/src/modules/auth/components/loginPage.tsx b/apps/frontend/src/modules/auth/components/loginPage.tsx index 5551d6d3..bbf92854 100644 --- a/apps/frontend/src/modules/auth/components/loginPage.tsx +++ b/apps/frontend/src/modules/auth/components/loginPage.tsx @@ -8,6 +8,7 @@ import Link from 'next/link'; import { baseApiURL } from '@web/lib/axios'; + import { CopyrightFooter } from '../../shared/components/layout/CopyrightFooter'; import { NoteBlockWorldLogo } from '../../shared/components/NoteBlockWorldLogo'; diff --git a/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx b/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx index 9ba806f1..06367fd3 100644 --- a/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx +++ b/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx @@ -1,7 +1,8 @@ -import { LoginForm } from './client/LoginFrom'; import { CopyrightFooter } from '../../shared/components/layout/CopyrightFooter'; import { NoteBlockWorldLogo } from '../../shared/components/NoteBlockWorldLogo'; +import { LoginForm } from './client/LoginFrom'; + export const LoginWithEmailPage = () => { return (
{
diff --git a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx index 4ea9ecb0..e9dd3d19 100644 --- a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx +++ b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx @@ -3,13 +3,6 @@ import { faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { CategoryButtonGroup } from './client/CategoryButton'; -import { useFeaturedSongsProvider } from './client/context/FeaturedSongs.context'; -import { useRecentSongsProvider } from './client/context/RecentSongs.context'; -import LoadMoreButton from './client/LoadMoreButton'; -import { TimespanButtonGroup } from './client/TimespanButton'; -import SongCard from './SongCard'; -import SongCardGroup from './SongCardGroup'; import { InterSectionAdSlot, SongCardAdSlot, @@ -24,6 +17,14 @@ import { } from '../../shared/components/client/Carousel'; import { WelcomeBanner } from '../WelcomeBanner'; +import { CategoryButtonGroup } from './client/CategoryButton'; +import { useFeaturedSongsProvider } from './client/context/FeaturedSongs.context'; +import { useRecentSongsProvider } from './client/context/RecentSongs.context'; +import LoadMoreButton from './client/LoadMoreButton'; +import { TimespanButtonGroup } from './client/TimespanButton'; +import SongCard from './SongCard'; +import SongCardGroup from './SongCardGroup'; + export const HomePageComponent = () => { const { featuredSongsPage } = useFeaturedSongsProvider(); @@ -46,8 +47,8 @@ export const HomePageComponent = () => { diff --git a/apps/frontend/src/modules/browse/components/SongCard.tsx b/apps/frontend/src/modules/browse/components/SongCard.tsx index d5025023..3883e3e5 100644 --- a/apps/frontend/src/modules/browse/components/SongCard.tsx +++ b/apps/frontend/src/modules/browse/components/SongCard.tsx @@ -8,6 +8,7 @@ import Skeleton from 'react-loading-skeleton'; import { formatDuration, formatTimeAgo } from '@web/modules/shared/util/format'; + import SongThumbnail from '../../shared/components/layout/SongThumbnail'; const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { diff --git a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx index 0de6bb2c..615bdb4c 100644 --- a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx +++ b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx @@ -28,11 +28,11 @@ export const CategoryButtonGroup = () => { diff --git a/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx index 8b32d8a1..2e1c6fa2 100644 --- a/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx @@ -43,7 +43,6 @@ export function FeaturedSongsProvider({ ); useEffect(() => { - // eslint-disable-next-line react-hooks/exhaustive-deps setFeaturedSongsPage(featuredSongs[timespan]); }, [featuredSongs, timespan]); diff --git a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx index 8d7fe9b7..f02d1130 100644 --- a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx @@ -1,16 +1,11 @@ 'use client'; import { SongPreviewDtoType } from '@nbw/database'; -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; +import { createContext, useCallback, useContext, useEffect, useState, } from 'react'; import axiosInstance from '@web/lib/axios'; + type RecentSongsContextType = { recentSongs: (SongPreviewDtoType | null)[]; recentError: string; @@ -43,7 +38,7 @@ export function RecentSongsProvider({ const [hasMore, setHasMore] = useState(true); const [categories, setCategories] = useState>({}); const [selectedCategory, setSelectedCategory] = useState(''); - const [endpoint, setEndpoint] = useState('/song-browser/recent'); + const [endpoint, setEndpoint] = useState('/song?q=recent'); const adCount = 1; const pageSize = 12; @@ -97,7 +92,7 @@ export function RecentSongsProvider({ const fetchCategories = useCallback(async function () { try { const response = await axiosInstance.get>( - '/song-browser/categories', + '/songs?q=categories', ); return response.data; @@ -119,8 +114,8 @@ export function RecentSongsProvider({ const newEndpoint = selectedCategory === '' - ? '/song-browser/recent' - : `/song-browser/categories/${selectedCategory}`; + ? '/song?q=recent' + : `/songs?q=categories&id=${selectedCategory}`; setEndpoint(newEndpoint); }, [selectedCategory]); diff --git a/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx b/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx index 93ee1b15..3e62542c 100644 --- a/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx +++ b/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx @@ -3,9 +3,10 @@ import type { SongPageDtoType, SongsFolder } from '@nbw/database'; import axiosInstance from '@web/lib/axios'; +import { getTokenServer } from '../../auth/features/auth.utils'; + import { MySongProvider } from './client/context/MySongs.context'; import { MySongsPageComponent } from './client/MySongsTable'; -import { getTokenServer } from '../../auth/features/auth.utils'; async function fetchSongsPage( page: number, @@ -18,9 +19,9 @@ async function fetchSongsPage( authorization: `Bearer ${token}`, }, params: { - page: page + 1, + page : page + 1, limit: pageSize, - sort: 'createdAt', + sort : 'createdAt', order: false, }, }) @@ -62,9 +63,9 @@ async function fetchSongsFolder(): Promise { return { 0: { content: [], - total: 0, - page: 0, - limit: 0, + total : 0, + page : 0, + limit : 0, }, }; } diff --git a/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx b/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx index 2140824d..dd76e6ee 100644 --- a/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx @@ -12,6 +12,7 @@ import Skeleton from 'react-loading-skeleton'; import { ErrorBox } from '@web/modules/shared/components/client/ErrorBox'; + import { useMySongsProvider } from './context/MySongs.context'; import DeleteConfirmDialog from './DeleteConfirmDialog'; import { SongRow } from './SongRow'; diff --git a/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx b/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx index 7bc60297..da97a5a9 100644 --- a/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx @@ -12,13 +12,15 @@ import Skeleton from 'react-loading-skeleton'; import SongThumbnail from '@web/modules/shared/components/layout/SongThumbnail'; import { formatDuration } from '@web/modules/shared/util/format'; -import { useMySongsProvider } from './context/MySongs.context'; + import { DeleteButton, DownloadSongButton, EditButton, } from '../client/MySongsButtons'; +import { useMySongsProvider } from './context/MySongs.context'; + export const SongRow = ({ song }: { song?: SongPreviewDtoType | null }) => { const { setIsDeleteDialogOpen, setSongToDelete } = useMySongsProvider(); @@ -123,9 +125,9 @@ export const SongRow = ({ song }: { song?: SongPreviewDtoType | null }) => { ) : ( <> {new Date(song.createdAt).toLocaleDateString('en-US', { - day: 'numeric', + day : 'numeric', month: 'short', - year: 'numeric', + year : 'numeric', })} )} diff --git a/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx b/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx index 72737afd..ccde434f 100644 --- a/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx @@ -18,6 +18,7 @@ import { toast } from 'react-hot-toast'; import axiosInstance from '@web/lib/axios'; import { getTokenLocal } from '@web/lib/axios/token.utils'; + type MySongsContextType = { page: SongPageDtoType | null; nextpage: () => void; @@ -86,9 +87,9 @@ export const MySongProvider = ({ try { const response = await axiosInstance.get('/my-songs', { params: { - page: currentPage, + page : currentPage, limit: pageSize, - sort: 'createdAt', + sort : 'createdAt', order: 'false', }, headers: { @@ -100,7 +101,7 @@ export const MySongProvider = ({ // TODO: total, page and pageSize are stored in every page, when it should be stored in the folder (what matters is 'content') putPage({ - key: currentPage, + key : currentPage, page: data, }); diff --git a/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx b/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx index 83c97fd2..c2dcfc31 100644 --- a/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx +++ b/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { cn } from '@web/lib/tailwind.utils'; + export const NoteBlockWorldLogo = ({ size, orientation = 'adaptive', diff --git a/apps/frontend/src/modules/shared/components/client/FormElements.tsx b/apps/frontend/src/modules/shared/components/client/FormElements.tsx index 5d453377..c107be1b 100644 --- a/apps/frontend/src/modules/shared/components/client/FormElements.tsx +++ b/apps/frontend/src/modules/shared/components/client/FormElements.tsx @@ -8,6 +8,7 @@ import Markdown from 'react-markdown'; import { cn } from '@web/lib/tailwind.utils'; import { ErrorBalloon } from '@web/modules/shared/components/client/ErrorBalloon'; + import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; export const Label = forwardRef< diff --git a/apps/frontend/src/modules/shared/components/client/UserMenuButton.tsx b/apps/frontend/src/modules/shared/components/client/UserMenuButton.tsx index 23adda59..23ed88b1 100644 --- a/apps/frontend/src/modules/shared/components/client/UserMenuButton.tsx +++ b/apps/frontend/src/modules/shared/components/client/UserMenuButton.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import { LoggedUserData } from '@web/modules/auth/types/User'; + export function UserMenuButton({ userData }: { userData: LoggedUserData }) { return ( <> diff --git a/apps/frontend/src/modules/shared/components/client/ads/AdSlots.tsx b/apps/frontend/src/modules/shared/components/client/ads/AdSlots.tsx index 9128fba9..72d68608 100644 --- a/apps/frontend/src/modules/shared/components/client/ads/AdSlots.tsx +++ b/apps/frontend/src/modules/shared/components/client/ads/AdSlots.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { cn } from '@web/lib/tailwind.utils'; + import useAdSenseClient from './useAdSenseClient'; const HideAdButton = ({ @@ -18,10 +19,8 @@ const HideAdButton = ({