diff --git a/apps/backend/package.json b/apps/backend/package.json index 6f7094ac..369d149a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -27,6 +27,7 @@ "@encode42/nbs.js": "^5.0.2", "@nbw/config": "workspace:*", "@nbw/database": "workspace:*", + "@nbw/validation": "workspace:*", "@nbw/song": "workspace:*", "@nbw/sounds": "workspace:*", "@nbw/thumbnail": "workspace:*", @@ -44,10 +45,10 @@ "axios": "^1.13.2", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", "esm": "^3.2.25", "express": "^5.2.1", "mongoose": "^9.0.1", + "nestjs-zod": "^5.0.1", "multer": "2.1.1", "nanoid": "^5.1.6", "passport": "^0.7.0", diff --git a/apps/backend/scripts/build.ts b/apps/backend/scripts/build.ts index 2dbb03c2..ca382333 100644 --- a/apps/backend/scripts/build.ts +++ b/apps/backend/scripts/build.ts @@ -51,9 +51,6 @@ const build = async () => { await Bun.$`rm -rf dist`; const optionalRequirePackages = [ - 'class-transformer', - 'class-transformer/storage', - 'class-validator', '@nestjs/microservices', '@nestjs/websockets', '@fastify/static', @@ -76,8 +73,11 @@ const build = async () => { }), '@nbw/config', '@nbw/database', + '@nbw/validation', '@nbw/song', '@nbw/sounds', + // @nestjs/swagger → @nestjs/mapped-types requires class-transformer metadata storage; bundler mis-resolves subpaths + 'class-transformer', ], splitting: true, }); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 215ce497..9eb1473f 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,17 +1,19 @@ import { Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_PIPE } from '@nestjs/core'; import { MongooseModule, MongooseModuleFactoryOptions } from '@nestjs/mongoose'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ZodValidationPipe } from 'nestjs-zod'; import { MailerModule } from '@nestjs-modules/mailer'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { AuthModule } from './auth/auth.module'; -import { validate } from './config/EnvironmentVariables'; +import { validateEnv } from '@nbw/validation'; import { EmailLoginModule } from './email-login/email-login.module'; import { FileModule } from './file/file.module'; import { ParseTokenPipe } from './lib/parseToken'; import { MailingModule } from './mailing/mailing.module'; +import { ProfileModule } from './profile/profile.module'; import { SeedModule } from './seed/seed.module'; import { SongModule } from './song/song.module'; import { UserModule } from './user/user.module'; @@ -21,7 +23,7 @@ import { UserModule } from './user/user.module'; ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.test', '.env.development', '.env.production'], - validate, + validate: validateEnv, }), //DatabaseModule, MongooseModule.forRootAsync({ @@ -73,6 +75,7 @@ import { UserModule } from './user/user.module'; ]), SongModule, UserModule, + ProfileModule, AuthModule.forRootAsync(), FileModule.forRootAsync(), SeedModule.forRoot(), @@ -82,6 +85,10 @@ import { UserModule } from './user/user.module'; controllers: [], providers: [ ParseTokenPipe, + { + provide: APP_PIPE, + useClass: ZodValidationPipe, + }, { provide: APP_GUARD, useClass: ThrottlerGuard, diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index add4d1dc..8b026f31 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -278,6 +278,7 @@ describe('AuthService', () => { profileImage: 'http://example.com/photo.jpg', }; + mockUserService.generateUsername.mockResolvedValue('testuser'); mockUserService.findByEmail.mockResolvedValue(null); mockUserService.create.mockResolvedValue({ id: 'new-user-id' }); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index ae96220a..e9bb66fe 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -4,8 +4,8 @@ import axios from 'axios'; import type { CookieOptions, Request, Response } from 'express'; import ms from 'ms'; -import { CreateUser } from '@nbw/database'; import type { UserDocument } from '@nbw/database'; +import { createUserSchema } from '@nbw/validation'; import { UserService } from '@server/user/user.service'; import { DiscordUser } from './types/discordProfile'; @@ -90,10 +90,9 @@ export class AuthService { private async createNewUser(user: Profile) { const { username, email, profileImage } = user; - const baseUsername = username; - const newUsername = await this.userService.generateUsername(baseUsername); + const newUsername = await this.userService.generateUsername(username); - const newUser = new CreateUser({ + const newUser = createUserSchema.parse({ username: newUsername, email: email, profileImage: profileImage, @@ -220,8 +219,6 @@ export class AuthService { return null; } - const user = await this.userService.findByID(decoded.id); - - return user; + return await this.userService.findByID(decoded.id); } } diff --git a/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts b/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts deleted file mode 100644 index af8b44f2..00000000 --- a/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - IsArray, - IsBoolean, - IsEnum, - IsNumber, - IsOptional, - IsString, -} from 'class-validator'; -import { - StrategyOptions as OAuth2StrategyOptions, - StrategyOptionsWithRequest as OAuth2StrategyOptionsWithRequest, -} from 'passport-oauth2'; - -import type { ScopeType } from './types'; - -type MergedOAuth2StrategyOptions = - | OAuth2StrategyOptions - | OAuth2StrategyOptionsWithRequest; - -type DiscordStrategyOptions = Pick< - MergedOAuth2StrategyOptions, - 'clientID' | 'clientSecret' | 'scope' ->; - -export class DiscordStrategyConfig implements DiscordStrategyOptions { - // The client ID assigned by Discord. - @IsString() - clientID: string; - - // The client secret assigned by Discord. - @IsString() - clientSecret: string; - - // The URL to which Discord will redirect the user after granting authorization. - @IsString() - callbackUrl: string; - - // An array of permission scopes to request. - @IsArray() - @IsString({ each: true }) - scope: ScopeType; - - // The delay in milliseconds between requests for the same scope. - @IsOptional() - @IsNumber() - scopeDelay?: number; - - // Whether to fetch data for the specified scope. - @IsOptional() - @IsBoolean() - fetchScope?: boolean; - - @IsEnum(['none', 'consent']) - prompt: 'consent' | 'none'; - - // The separator for the scope values. - @IsOptional() - @IsString() - scopeSeparator?: string; -} 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..e9f671d6 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts @@ -1,6 +1,7 @@ import { VerifyFunction } from 'passport-oauth2'; -import { DiscordStrategyConfig } from './DiscordStrategyConfig'; +import type { DiscordStrategyConfig } from '@nbw/validation'; + import DiscordStrategy from './Strategy'; import { DiscordPermissionScope, Profile } from './types'; @@ -42,16 +43,15 @@ describe('DiscordStrategy', () => { prompt: 'consent', }; - await expect(strategy['validateConfig'](config)).resolves.toBeUndefined(); + expect(() => strategy['validateConfig'](config)).not.toThrow(); }); it('should make API request', async () => { - const mockGet = jest.fn((url, accessToken, callback) => { - callback(null, JSON.stringify({ id: '123' })); + strategy['_oauth2'].get = jest.fn((url, accessToken, callback) => { + // oauth2 `dataCallback` typings omit `null`; runtime passes null on success. + callback(null as never, JSON.stringify({ id: '123' })); }); - strategy['_oauth2'].get = mockGet; - const result = await strategy['makeApiRequest']<{ id: string }>( 'https://discord.com/api/users/@me', 'test-access-token', diff --git a/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts b/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts index ebc6d905..8459c5f2 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts @@ -1,6 +1,8 @@ import { Logger } from '@nestjs/common'; -import { plainToClass } from 'class-transformer'; -import { validateOrReject } from 'class-validator'; +import { + discordStrategyConfigSchema, + type DiscordStrategyConfig, +} from '@nbw/validation'; import { InternalOAuthError, Strategy as OAuth2Strategy, @@ -8,8 +10,6 @@ import { VerifyCallback, VerifyFunction, } from 'passport-oauth2'; - -import { DiscordStrategyConfig } from './DiscordStrategyConfig'; import { Profile, ProfileConnection, @@ -47,20 +47,19 @@ export default class Strategy extends OAuth2Strategy { ); this.validateConfig(options); - this.scope = options.scope; + this.scope = options.scope as ScopeType; this.scopeDelay = options.scopeDelay ?? 0; this.fetchScopeEnabled = options.fetchScope ?? true; this._oauth2.useAuthorizationHeaderforGET(true); this.prompt = options.prompt; } - private async validateConfig(config: DiscordStrategyConfig): Promise { + private validateConfig(config: DiscordStrategyConfig): void { try { - const validatedConfig = plainToClass(DiscordStrategyConfig, config); - await validateOrReject(validatedConfig); - } catch (errors) { - this.logger.error(errors); - throw new Error(`Configuration validation failed: ${errors}`); + discordStrategyConfigSchema.parse(config); + } catch (error) { + this.logger.error(error); + throw new Error(`Configuration validation failed: ${String(error)}`); } } diff --git a/apps/backend/src/config/EnvironmentVariables.ts b/apps/backend/src/config/EnvironmentVariables.ts deleted file mode 100644 index a933ce57..00000000 --- a/apps/backend/src/config/EnvironmentVariables.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { - IsEnum, - IsOptional, - IsString, - registerDecorator, - validateSync, - ValidationArguments, - ValidationOptions, -} from 'class-validator'; -import ms from 'ms'; - -// Validate if the value is a valid duration string from the 'ms' library -function IsDuration(validationOptions?: ValidationOptions) { - return function (object: object, propertyName: string) { - registerDecorator({ - name: 'isDuration', - target: object.constructor, - propertyName: propertyName, - options: validationOptions, - validator: { - validate(value: unknown) { - if (typeof value !== 'string') return false; - return typeof ms(value as ms.StringValue) === 'number'; - }, - defaultMessage(args: ValidationArguments) { - return `${args.property} must be a valid duration string (e.g., "1h", "30m", "7d")`; - }, - }, - }); - }; -} - -enum Environment { - Development = 'development', - Production = 'production', -} - -export class EnvironmentVariables { - @IsEnum(Environment) - @IsOptional() - NODE_ENV?: Environment; - - // OAuth providers - @IsString() - GITHUB_CLIENT_ID: string; - - @IsString() - GITHUB_CLIENT_SECRET: string; - - @IsString() - GOOGLE_CLIENT_ID: string; - - @IsString() - GOOGLE_CLIENT_SECRET: string; - - @IsString() - DISCORD_CLIENT_ID: string; - - @IsString() - DISCORD_CLIENT_SECRET: string; - - // Email magic link auth - @IsString() - MAGIC_LINK_SECRET: string; - - // jwt auth - @IsString() - JWT_SECRET: string; - - @IsDuration() - JWT_EXPIRES_IN: ms.StringValue; - - @IsString() - JWT_REFRESH_SECRET: string; - - @IsDuration() - JWT_REFRESH_EXPIRES_IN: ms.StringValue; - - // database - @IsString() - MONGO_URL: string; - - @IsString() - SERVER_URL: string; - - @IsString() - FRONTEND_URL: string; - - @IsString() - @IsOptional() - APP_DOMAIN: string = 'localhost'; - - @IsString() - RECAPTCHA_KEY: string; - - // s3 - @IsString() - S3_ENDPOINT: string; - - @IsString() - S3_BUCKET_SONGS: string; - - @IsString() - S3_BUCKET_THUMBS: string; - - @IsString() - S3_KEY: string; - - @IsString() - S3_SECRET: string; - - @IsString() - S3_REGION: string; - - @IsString() - @IsOptional() - WHITELISTED_USERS?: string; - - // discord webhook - @IsString() - DISCORD_WEBHOOK_URL: string; - - @IsDuration() - COOKIE_EXPIRES_IN: ms.StringValue; -} - -export function validate(config: Record) { - const validatedConfig = plainToInstance(EnvironmentVariables, config, { - enableImplicitConversion: true, - }); - - const errors = validateSync(validatedConfig, { - skipMissingProperties: false, - }); - - if (errors.length > 0) { - const messages = errors - .map((error) => { - const constraints = Object.values(error.constraints || {}); - return ` - ${error.property}: ${constraints.join(', ')}`; - }) - .join('\n'); - throw new Error(`Environment validation failed:\n${messages}`); - } - - return validatedConfig; -} diff --git a/apps/backend/src/lib/initializeSwagger.spec.ts b/apps/backend/src/lib/initializeSwagger.spec.ts index 25fa9b42..2268c712 100644 --- a/apps/backend/src/lib/initializeSwagger.spec.ts +++ b/apps/backend/src/lib/initializeSwagger.spec.ts @@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; import { initializeSwagger } from './initializeSwagger'; +mock.module('nestjs-zod', () => ({ + cleanupOpenApiDoc: (doc: unknown) => doc, +})); + mock.module('@nestjs/swagger', () => ({ DocumentBuilder: jest.fn().mockImplementation(() => ({ setTitle: jest.fn().mockReturnThis(), diff --git a/apps/backend/src/lib/initializeSwagger.ts b/apps/backend/src/lib/initializeSwagger.ts index 2e45498c..0055e9cf 100644 --- a/apps/backend/src/lib/initializeSwagger.ts +++ b/apps/backend/src/lib/initializeSwagger.ts @@ -1,11 +1,38 @@ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, + type OpenAPIObject, SwaggerCustomOptions, SwaggerModule, } from '@nestjs/swagger'; +import { cleanupOpenApiDoc } from 'nestjs-zod'; -export function initializeSwagger(app: INestApplication) { +/** nestjs-zod internal; must not sit on OpenAPI *parameter* objects (only in schemas). */ +const ZOD_UNWRAP_ROOT = 'x-nestjs_zod-unwrap-root' as const; + +function stripInvalidZodMarkersFromParameters(doc: OpenAPIObject) { + // `cleanupOpenApiDoc` keeps this zod marker valid inside schema objects, but when it leaks + // into operation parameters Swagger UI/OpenAPI validators flag the document as invalid. + // We remove it here so generated docs remain standards-compliant and render reliably. + const paths = doc.paths; + if (!paths) return; + for (const pathItem of Object.values(paths)) { + if (!pathItem || typeof pathItem !== 'object') continue; + for (const methodObject of Object.values(pathItem)) { + if (!methodObject || typeof methodObject !== 'object') continue; + const parameters = (methodObject as { parameters?: unknown[] }) + .parameters; + if (!Array.isArray(parameters)) continue; + for (const param of parameters) { + if (param && typeof param === 'object' && ZOD_UNWRAP_ROOT in param) { + delete (param as Record)[ZOD_UNWRAP_ROOT]; + } + } + } + } +} + +export function initializeSwagger(app: INestApplication) { const config = new DocumentBuilder() .setTitle('NoteBlockWorld API Backend') .setDescription('Backend application for NoteBlockWorld') @@ -13,7 +40,9 @@ export function initializeSwagger(app: INestApplication) { .addBearerAuth() .build(); - const document = SwaggerModule.createDocument(app, config); + const raw = SwaggerModule.createDocument(app, config); + stripInvalidZodMarkersFromParameters(raw); + const document = cleanupOpenApiDoc(raw); const swaggerOptions: SwaggerCustomOptions = { swaggerOptions: { diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 2fd6a1ed..9a4b8836 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,4 +1,4 @@ -import { Logger, ValidationPipe } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import * as express from 'express'; @@ -16,15 +16,6 @@ async function bootstrap() { app.useGlobalGuards(parseTokenPipe); - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - transformOptions: { - enableImplicitConversion: true, - }, - }), - ); - app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); diff --git a/apps/backend/src/profile/profile.controller.ts b/apps/backend/src/profile/profile.controller.ts new file mode 100644 index 00000000..54094d78 --- /dev/null +++ b/apps/backend/src/profile/profile.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Inject, Param, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import type { UserDocument } from '@nbw/database'; +import type { PublicProfileDto } from '@nbw/validation'; +import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; +import { PatchProfileBodyDto, ProfileUsernameParamDto } from '@server/zod-dto'; + +import { ProfileService } from './profile.service'; + +@Controller('profile') +export class ProfileController { + constructor( + @Inject(ProfileService) + private readonly profileService: ProfileService, + ) {} + + @Patch() + @ApiTags('profile') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update the authenticated user profile (Profile document)', + }) + public async patchProfile( + @GetRequestToken() user: UserDocument | null, + @Body() body: PatchProfileBodyDto, + ): Promise { + user = validateUser(user); + return await this.profileService.patchProfile(user, body); + } + + @Get('u/:username') + @ApiTags('profile') + @ApiOperation({ + summary: + 'Get public profile by normalized username (path matches User.username)', + }) + public async getPublicProfile( + @Param() params: ProfileUsernameParamDto, + ): Promise { + return await this.profileService.getMergedPublicProfileByUsername( + params.username, + ); + } +} diff --git a/apps/backend/src/profile/profile.module.ts b/apps/backend/src/profile/profile.module.ts new file mode 100644 index 00000000..44230df7 --- /dev/null +++ b/apps/backend/src/profile/profile.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { Profile, ProfileSchema } from '@nbw/database'; + +import { UserModule } from '@server/user/user.module'; + +import { ProfileController } from './profile.controller'; +import { ProfileService } from './profile.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Profile.name, schema: ProfileSchema }]), + UserModule, + ], + controllers: [ProfileController], + providers: [ProfileService], + exports: [ProfileService], +}) +export class ProfileModule {} diff --git a/apps/backend/src/profile/profile.service.spec.ts b/apps/backend/src/profile/profile.service.spec.ts new file mode 100644 index 00000000..6913c631 --- /dev/null +++ b/apps/backend/src/profile/profile.service.spec.ts @@ -0,0 +1,209 @@ +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import mongoose from 'mongoose'; + +import { Profile, type UserDocument } from '@nbw/database'; +import type { PatchProfileBody } from '@nbw/validation'; +import { UserService } from '@server/user/user.service'; + +import { + mergeSocialLinks, + ProfileService, + sanitizeSocialLinksForPublic, + toPublicProfileDto, +} from './profile.service'; + +describe('ProfileService helpers', () => { + it('sanitizeSocialLinksForPublic removes Mongo _id and keeps only known string URLs', () => { + expect( + sanitizeSocialLinksForPublic({ + _id: '67cf5fd96a6e68a6aa291d2a', + github: 'https://github.com/x', + discord: '', + } as Record), + ).toEqual({ github: 'https://github.com/x' }); + }); + + it('mergeSocialLinks overlays profile keys onto user', () => { + expect( + mergeSocialLinks( + { github: 'https://a.com', x: 'https://x.com/u' }, + { github: 'https://b.com' }, + ), + ).toEqual({ github: 'https://b.com', x: 'https://x.com/u' }); + }); + + it('toPublicProfileDto uses User when Profile is null', () => { + const user = { + _id: new mongoose.Types.ObjectId(), + username: 'u', + publicName: 'U', + profileImage: '/img.png', + description: 'from user', + socialLinks: { github: 'https://gh' }, + } as unknown as UserDocument; + + const dto = toPublicProfileDto(user, null); + expect(dto.description).toBe('from user'); + expect(dto.socialLinks.github).toBe('https://gh'); + }); + + it('toPublicProfileDto prefers Profile description when document exists', () => { + const user = { + _id: new mongoose.Types.ObjectId(), + username: 'u', + publicName: 'U', + profileImage: '/img.png', + description: 'from user', + socialLinks: { github: 'https://user-gh' }, + } as unknown as UserDocument; + + const profile = { + description: 'from profile', + socialLinks: { x: 'https://x.com' }, + } as any; + + const dto = toPublicProfileDto(user, profile); + expect(dto.description).toBe('from profile'); + expect(dto.socialLinks).toEqual({ + github: 'https://user-gh', + x: 'https://x.com', + }); + }); +}); + +describe('ProfileService', () => { + let service: ProfileService; + const mockUserService = { + findByID: jest.fn(), + findByUsername: jest.fn(), + normalizeUsername: jest.fn((s: string) => s), + update: jest.fn(), + }; + + const mockProfileModel = { + findOne: jest.fn(), + create: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProfileService, + { + provide: getModelToken(Profile.name), + useValue: mockProfileModel, + }, + { + provide: UserService, + useValue: mockUserService, + }, + ], + }).compile(); + + service = module.get(ProfileService); + }); + + it('getMergedPublicProfile throws when user missing', async () => { + mockUserService.findByID.mockResolvedValue(null); + await expect( + service.getMergedPublicProfile(new mongoose.Types.ObjectId().toString()), + ).rejects.toThrow('User not found'); + }); + + it('getMergedPublicProfile returns merged dto', async () => { + const id = new mongoose.Types.ObjectId(); + mockUserService.findByID.mockResolvedValue({ + _id: id, + username: 'a', + publicName: 'A', + profileImage: '/p.jpg', + description: 'bio', + socialLinks: {}, + }); + mockProfileModel.findOne.mockReturnValue({ + exec: jest.fn().mockResolvedValue(null), + }); + + const dto = await service.getMergedPublicProfile(id.toString()); + expect(dto.username).toBe('a'); + expect(dto.id).toBe(id.toString()); + }); + + it('getMergedPublicProfileByUsername normalizes and loads by username', async () => { + const id = new mongoose.Types.ObjectId(); + const userDoc = { + _id: id, + username: 'alice', + publicName: 'Alice', + profileImage: '/p.jpg', + description: 'bio', + socialLinks: {}, + }; + mockUserService.normalizeUsername.mockReturnValue('alice'); + mockUserService.findByUsername.mockResolvedValue(userDoc); + mockProfileModel.findOne.mockReturnValue({ + exec: jest.fn().mockResolvedValue(null), + }); + + const dto = await service.getMergedPublicProfileByUsername('alice'); + expect(dto.username).toBe('alice'); + expect(mockUserService.normalizeUsername).toHaveBeenCalledWith('alice'); + expect(mockUserService.findByUsername).toHaveBeenCalledWith('alice'); + }); + + it('getMergedPublicProfileByUsername throws when username unknown', async () => { + mockUserService.normalizeUsername.mockReturnValue('nobody'); + mockUserService.findByUsername.mockResolvedValue(null); + await expect( + service.getMergedPublicProfileByUsername('nobody'), + ).rejects.toThrow('User not found'); + }); + + it('patchProfile updates publicName via UserService.update', async () => { + const id = new mongoose.Types.ObjectId(); + const userDoc = { + _id: id, + username: 'alice', + publicName: 'Old', + profileImage: '/p.jpg', + description: '', + socialLinks: {}, + } as UserDocument; + + mockUserService.findByID.mockResolvedValue(userDoc); + mockUserService.update.mockImplementation(async (u: UserDocument) => u); + mockProfileModel.findOne.mockReturnValue({ + exec: jest.fn().mockResolvedValue(null), + }); + + const body: PatchProfileBody = { publicName: 'New Name' }; + const dto = await service.patchProfile(userDoc, body); + + expect(dto.publicName).toBe('New Name'); + expect(mockUserService.update).toHaveBeenCalled(); + }); + + it('patchProfile no-op body returns current profile', async () => { + const id = new mongoose.Types.ObjectId(); + const userDoc = { + _id: id, + username: 'alice', + publicName: 'Alice', + profileImage: '/p.jpg', + description: '', + socialLinks: {}, + } as UserDocument; + + mockUserService.findByID.mockResolvedValue(userDoc); + mockProfileModel.findOne.mockReturnValue({ + exec: jest.fn().mockResolvedValue(null), + }); + + const dto = await service.patchProfile(userDoc, {}); + + expect(dto.publicName).toBe('Alice'); + expect(mockUserService.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/profile/profile.service.ts b/apps/backend/src/profile/profile.service.ts new file mode 100644 index 00000000..f0bbc1e7 --- /dev/null +++ b/apps/backend/src/profile/profile.service.ts @@ -0,0 +1,181 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; + +import { Profile, ProfileDocument, UserDocument } from '@nbw/database'; +import type { PatchProfileBody, PublicProfileDto } from '@nbw/validation'; +import { UserService } from '@server/user/user.service'; + +/** Matches `SocialLinks` in user/profile entities — excludes Mongoose `_id` on embedded docs. */ +const PUBLIC_SOCIAL_KEYS = [ + 'bandcamp', + 'discord', + 'facebook', + 'github', + 'instagram', + 'reddit', + 'snapchat', + 'soundcloud', + 'spotify', + 'steam', + 'telegram', + 'tiktok', + 'threads', + 'twitch', + 'x', + 'youtube', +] as const; + +export function mergeSocialLinks( + userLinks: Record | undefined, + profileLinks: Record | undefined, +): Record { + return { + ...(userLinks ?? {}), + ...(profileLinks ?? {}), + }; +} + +/** Strip Mongo subdocument fields (e.g. `_id`) and unknown keys from API output. */ +export function sanitizeSocialLinksForPublic( + raw: Record | null | undefined, +): Record { + if (!raw || typeof raw !== 'object') { + return {}; + } + const out: Record = {}; + for (const key of PUBLIC_SOCIAL_KEYS) { + const v = raw[key]; + if (typeof v === 'string' && v.trim() !== '') { + out[key] = v; + } + } + return out; +} + +export function toPublicProfileDto( + user: UserDocument, + profile: ProfileDocument | null, +): PublicProfileDto { + const description = profile !== null ? profile.description : user.description; + + const merged = + profile !== null + ? mergeSocialLinks( + user.socialLinks as Record, + profile.socialLinks as Record, + ) + : (user.socialLinks as Record); + + const socialLinks = sanitizeSocialLinksForPublic( + merged as Record, + ); + + return { + id: user._id.toString(), + username: user.username, + publicName: user.publicName, + profileImage: user.profileImage, + description, + socialLinks, + }; +} + +@Injectable() +export class ProfileService { + constructor( + @InjectModel(Profile.name) private profileModel: Model, + private readonly userService: UserService, + ) {} + + public async getMergedPublicProfile( + userId: string, + ): Promise { + const user = await this.userService.findByID(userId); + + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + const profile = await this.profileModel.findOne({ user: user._id }).exec(); + + return toPublicProfileDto(user, profile); + } + + /** + * Public profile by URL username segment (same normalization as registration). + * One User maps to at most one Profile document (enforced by unique index on `Profile.user`). + */ + public async getMergedPublicProfileByUsername( + rawUsername: string, + ): Promise { + const normalized = this.userService.normalizeUsername(rawUsername); + const user = await this.userService.findByUsername(normalized); + + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + const profile = await this.profileModel.findOne({ user: user._id }).exec(); + + return toPublicProfileDto(user, profile); + } + + public async patchProfile( + requester: UserDocument, + body: PatchProfileBody, + ): Promise { + let user = await this.userService.findByID(requester._id.toString()); + + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + if ( + body.description === undefined && + body.socialLinks === undefined && + body.publicName === undefined + ) { + const profile = await this.profileModel + .findOne({ user: requester._id }) + .exec(); + return toPublicProfileDto(user, profile); + } + + if (body.publicName !== undefined) { + user.publicName = body.publicName; + user = (await this.userService.update(user)) as UserDocument; + } + + if (body.description !== undefined || body.socialLinks !== undefined) { + let profile = await this.profileModel + .findOne({ user: requester._id }) + .exec(); + + if (!profile) { + profile = await this.profileModel.create({ + user: requester._id as Types.ObjectId, + description: body.description ?? '', + socialLinks: (body.socialLinks ?? {}) as Profile['socialLinks'], + }); + } else { + if (body.description !== undefined) { + profile.description = body.description; + } + if (body.socialLinks !== undefined) { + profile.socialLinks = mergeSocialLinks( + profile.socialLinks as Record, + body.socialLinks as Record, + ) as Profile['socialLinks']; + } + await profile.save(); + } + } + + const profileDoc = await this.profileModel + .findOne({ user: requester._id }) + .exec(); + + return toPublicProfileDto(user, profileDoc); + } +} diff --git a/apps/backend/src/seed/seed.service.ts b/apps/backend/src/seed/seed.service.ts index 18bec954..2b8b251d 100644 --- a/apps/backend/src/seed/seed.service.ts +++ b/apps/backend/src/seed/seed.service.ts @@ -9,14 +9,13 @@ import { } from '@nestjs/common'; import { UPLOAD_CONSTANTS } from '@nbw/config'; -import { +import { SongDocument, type UserDocument } from '@nbw/database'; +import type { CategoryType, LicenseType, - SongDocument, UploadSongDto, - UserDocument, VisibilityType, -} from '@nbw/database'; +} from '@nbw/validation'; import { SongService } from '@server/song/song.service'; import { UserService } from '@server/user/user.service'; 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..364be8ec 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 @@ -1,9 +1,10 @@ import type { UserDocument } from '@nbw/database'; -import { PageQueryDTO, SongPageDto } from '@nbw/database'; +import { type PageQueryInput, type SongPageDto } from '@nbw/validation'; import { HttpException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; +import type { PageQueryDto } from '../../zod-dto'; import { SongService } from '../song.service'; import { MySongsController } from './my-songs.controller'; @@ -39,7 +40,7 @@ describe('MySongsController', () => { describe('getMySongsPage', () => { it('should return a list of songs uploaded by the current authenticated user', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const songPageDto: SongPageDto = { @@ -51,31 +52,34 @@ describe('MySongsController', () => { mockSongService.getMySongsPage.mockResolvedValueOnce(songPageDto); - const result = await controller.getMySongsPage(query, user); + const result = await controller.getMySongsPage( + query as PageQueryDto, + user, + ); expect(result).toEqual(songPageDto); expect(songService.getMySongsPage).toHaveBeenCalledWith({ query, user }); }); it('should handle thrown an exception if userDocument is null', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const user = null; - await expect(controller.getMySongsPage(query, user)).rejects.toThrow( - HttpException, - ); + await expect( + controller.getMySongsPage(query as PageQueryDto, user), + ).rejects.toThrow(HttpException); }); it('should handle exceptions', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const user: UserDocument = { _id: 'test-user-id' } as UserDocument; const error = new Error('Test error'); mockSongService.getMySongsPage.mockRejectedValueOnce(error); - await expect(controller.getMySongsPage(query, user)).rejects.toThrow( - 'Test error', - ); + await expect( + controller.getMySongsPage(query as PageQueryDto, user), + ).rejects.toThrow('Test error'); }); }); }); diff --git a/apps/backend/src/song/my-songs/my-songs.controller.ts b/apps/backend/src/song/my-songs/my-songs.controller.ts index ece24049..ae9e0c55 100644 --- a/apps/backend/src/song/my-songs/my-songs.controller.ts +++ b/apps/backend/src/song/my-songs/my-songs.controller.ts @@ -2,9 +2,10 @@ import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { PageQueryDTO, SongPageDto } from '@nbw/database'; import type { UserDocument } from '@nbw/database'; +import type { SongPageDto } from '@nbw/validation'; import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; +import { PageQueryDto } from '@server/zod-dto'; import { SongService } from '../song.service'; @@ -24,7 +25,7 @@ export class MySongsController { @ApiBearerAuth() @UseGuards(AuthGuard('jwt-refresh')) public async getMySongsPage( - @Query() query: PageQueryDTO, + @Query() query: PageQueryDto, @GetRequestToken() user: UserDocument | null, ): Promise { user = validateUser(user); 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..83abc363 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,11 +1,7 @@ import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; import type { UserDocument } from '@nbw/database'; -import { - SongDocument, - Song as SongEntity, - ThumbnailData, - UploadSongDto, -} from '@nbw/database'; +import { SongDocument, Song as SongEntity } from '@nbw/database'; +import type { ThumbnailData, UploadSongDto } from '@nbw/validation'; import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; 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 b9ed8871..0e64efc8 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -1,4 +1,4 @@ -import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js'; +import { fromArrayBuffer, Song, toArrayBuffer } from '@encode42/nbs.js'; import { HttpException, HttpStatus, @@ -9,20 +9,18 @@ import { import { Types } from 'mongoose'; import { - SongDocument, Song as SongEntity, - SongStats, - ThumbnailData, - UploadSongDto, - UserDocument, + SongDocument, + type UserDocument, } from '@nbw/database'; import { - NoteQuadTree, - SongStatsGenerator, injectSongFileMetadata, + NoteQuadTree, obfuscateAndPackSong, + SongStatsGenerator, } from '@nbw/song'; import { drawToImage } from '@nbw/thumbnail/node'; +import type { SongStats, ThumbnailData, UploadSongDto } from '@nbw/validation'; import { FileService } from '@server/file/file.service'; import { UserService } from '@server/user/user.service'; @@ -124,11 +122,9 @@ export class SongUploadService { songStats.instrumentNoteCounts.length - songStats.firstCustomInstrumentIndex; - const paddedInstruments = body.customInstruments.concat( + song.customInstruments = body.customInstruments.concat( Array(customInstrumentCount - body.customInstruments.length).fill(''), ); - - song.customInstruments = paddedInstruments; song.thumbnailData = body.thumbnailData; song.thumbnailUrl = thumbUrl; song.nbsFileUrl = fileKey; // s3File.Location; @@ -317,13 +313,7 @@ export class SongUploadService { this.validateCustomInstruments(soundsArray, validSoundsSubset); - const packedSongBuffer = await obfuscateAndPackSong( - nbsSong, - soundsArray, - soundsMapping, - ); - - return packedSongBuffer; + return await obfuscateAndPackSong(nbsSong, soundsArray, soundsMapping); } private validateCustomInstruments( diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts index 362be7d7..78257bfa 100644 --- a/apps/backend/src/song/song.controller.spec.ts +++ b/apps/backend/src/song/song.controller.spec.ts @@ -1,20 +1,20 @@ -import type { UserDocument } from '@nbw/database'; -import { - PageQueryDTO, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, - PageDto, - SongListQueryDTO, - SongSortType, - FeaturedSongsDto, -} 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 type { UserDocument } from '@nbw/database'; +import { + type PageQueryInput, + type SongListQueryInput, + type SongPreviewDto, + SongSortType, + type SongViewDto, + type UploadSongDto, + type UploadSongResponseDto, +} from '@nbw/validation'; + +import type { SongListQueryDto, SongSearchQueryDto } from '../zod-dto'; import { FileService } from '../file/file.service'; import { SongController } from './song.controller'; @@ -75,7 +75,7 @@ describe('SongController', () => { describe('getSongList', () => { it('should return a paginated list of songs (default)', async () => { - const query: SongListQueryDTO = { page: 1, limit: 10 }; + const query: SongListQueryInput = { page: 1, limit: 10 }; const songList: SongPreviewDto[] = []; mockSongService.querySongs.mockResolvedValueOnce({ @@ -85,9 +85,10 @@ describe('SongController', () => { total: 0, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.page).toBe(1); expect(result.limit).toBe(10); @@ -96,7 +97,11 @@ describe('SongController', () => { }); it('should handle search query', async () => { - const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' }; + const query: SongListQueryInput = { + page: 1, + limit: 10, + q: 'test search', + }; const songList: SongPreviewDto[] = []; mockSongService.querySongs.mockResolvedValueOnce({ @@ -106,16 +111,17 @@ describe('SongController', () => { total: 0, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.total).toBe(0); expect(songService.querySongs).toHaveBeenCalled(); }); it('should handle random sort', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 5, sort: SongSortType.RANDOM, @@ -124,15 +130,16 @@ describe('SongController', () => { mockSongService.getRandomSongs.mockResolvedValueOnce(songList); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(songService.getRandomSongs).toHaveBeenCalledWith(5, undefined); }); it('should handle random sort with category', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 5, sort: SongSortType.RANDOM, @@ -142,15 +149,16 @@ describe('SongController', () => { mockSongService.getRandomSongs.mockResolvedValueOnce(songList); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(songService.getRandomSongs).toHaveBeenCalledWith(5, 'electronic'); }); it('should handle recent sort', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 10, sort: SongSortType.RECENT, @@ -164,9 +172,10 @@ describe('SongController', () => { total: 0, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.total).toBe(0); expect(songService.querySongs).toHaveBeenCalledWith( @@ -174,15 +183,16 @@ describe('SongController', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'desc', }), undefined, undefined, + undefined, ); }); it('should handle recent sort with category', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 10, sort: SongSortType.RECENT, @@ -197,9 +207,10 @@ describe('SongController', () => { total: 0, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.total).toBe(0); expect(songService.querySongs).toHaveBeenCalledWith( @@ -207,15 +218,16 @@ describe('SongController', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'desc', }), undefined, 'pop', + undefined, ); }); it('should handle category filter', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 10, category: 'rock', @@ -229,16 +241,17 @@ describe('SongController', () => { total: 0, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.total).toBe(0); expect(songService.querySongs).toHaveBeenCalled(); }); it('should return correct total when total exceeds limit', async () => { - const query: SongListQueryDTO = { page: 1, limit: 10 }; + const query: SongListQueryInput = { page: 1, limit: 10 }; const songList: SongPreviewDto[] = Array(10) .fill(null) .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); @@ -250,9 +263,10 @@ describe('SongController', () => { total: 150, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(10); expect(result.total).toBe(150); expect(result.page).toBe(1); @@ -260,7 +274,7 @@ describe('SongController', () => { }); it('should return correct total when total is less than limit', async () => { - const query: SongListQueryDTO = { page: 1, limit: 10 }; + const query: SongListQueryInput = { page: 1, limit: 10 }; const songList: SongPreviewDto[] = Array(5) .fill(null) .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); @@ -272,15 +286,16 @@ describe('SongController', () => { total: 5, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(5); expect(result.total).toBe(5); }); it('should return correct total on later pages', async () => { - const query: SongListQueryDTO = { page: 3, limit: 10 }; + const query: SongListQueryInput = { page: 3, limit: 10 }; const songList: SongPreviewDto[] = Array(10) .fill(null) .map((_, i) => ({ id: `song-${20 + i}` } as unknown as SongPreviewDto)); @@ -292,16 +307,21 @@ describe('SongController', () => { total: 25, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(10); expect(result.total).toBe(25); expect(result.page).toBe(3); }); it('should handle search query with total count', async () => { - const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' }; + const query: SongListQueryInput = { + page: 1, + limit: 10, + q: 'test search', + }; const songList: SongPreviewDto[] = Array(8) .fill(null) .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); @@ -313,16 +333,17 @@ describe('SongController', () => { total: 8, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(8); expect(result.total).toBe(8); expect(songService.querySongs).toHaveBeenCalled(); }); it('should handle category filter with total count', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 10, category: 'rock', @@ -338,20 +359,23 @@ describe('SongController', () => { total: 3, }); - const result = await songController.getSongList(query); + const result = await songController.getSongList( + query as SongListQueryDto, + ); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(3); expect(result.total).toBe(3); expect(songService.querySongs).toHaveBeenCalled(); }); it('should handle errors', async () => { - const query: SongListQueryDTO = { page: 1, limit: 10 }; + const query: SongListQueryInput = { page: 1, limit: 10 }; mockSongService.querySongs.mockRejectedValueOnce(new Error('Error')); - await expect(songController.getSongList(query)).rejects.toThrow('Error'); + await expect( + songController.getSongList(query as SongListQueryDto), + ).rejects.toThrow('Error'); }); }); @@ -409,7 +433,7 @@ describe('SongController', () => { describe('searchSongs', () => { it('should return paginated search results with query', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = 'test query'; const songList: SongPreviewDto[] = Array(5) .fill(null) @@ -422,9 +446,11 @@ describe('SongController', () => { total: 5, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(5); expect(result.total).toBe(5); expect(result.page).toBe(1); @@ -433,7 +459,7 @@ describe('SongController', () => { }); it('should handle search with empty query string', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = ''; const songList: SongPreviewDto[] = []; @@ -444,16 +470,18 @@ describe('SongController', () => { total: 0, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.total).toBe(0); expect(songService.querySongs).toHaveBeenCalledWith(query, ''); }); it('should handle search with null query string', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = null as any; const songList: SongPreviewDto[] = []; @@ -464,15 +492,17 @@ describe('SongController', () => { total: 0, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(songService.querySongs).toHaveBeenCalledWith(query, ''); }); it('should handle search with multiple pages', async () => { - const query: PageQueryDTO = { page: 2, limit: 10 }; + const query: PageQueryInput = { page: 2, limit: 10 }; const q = 'test search'; const songList: SongPreviewDto[] = Array(10) .fill(null) @@ -485,9 +515,11 @@ describe('SongController', () => { total: 25, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(10); expect(result.total).toBe(25); expect(result.page).toBe(2); @@ -495,7 +527,7 @@ describe('SongController', () => { }); it('should handle search with large result set', async () => { - const query: PageQueryDTO = { page: 1, limit: 50 }; + const query: PageQueryInput = { page: 1, limit: 50 }; const q = 'popular song'; const songList: SongPreviewDto[] = Array(50) .fill(null) @@ -508,16 +540,18 @@ describe('SongController', () => { total: 500, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(50); expect(result.total).toBe(500); expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search on last page with partial results', async () => { - const query: PageQueryDTO = { page: 5, limit: 10 }; + const query: PageQueryInput = { page: 5, limit: 10 }; const q = 'search term'; const songList: SongPreviewDto[] = Array(3) .fill(null) @@ -530,16 +564,18 @@ describe('SongController', () => { total: 43, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(3); expect(result.total).toBe(43); expect(result.page).toBe(5); }); it('should handle search with special characters', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = 'test@#$%^&*()'; const songList: SongPreviewDto[] = []; @@ -550,14 +586,16 @@ describe('SongController', () => { total: 0, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search with very long query string', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = 'a'.repeat(500); const songList: SongPreviewDto[] = []; @@ -568,14 +606,16 @@ describe('SongController', () => { total: 0, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search with custom limit', async () => { - const query: PageQueryDTO = { page: 1, limit: 25 }; + const query: PageQueryInput = { page: 1, limit: 25 }; const q = 'test'; const songList: SongPreviewDto[] = Array(25) .fill(null) @@ -588,20 +628,22 @@ describe('SongController', () => { total: 100, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(25); expect(result.limit).toBe(25); expect(result.total).toBe(100); }); it('should handle search with sorting parameters', async () => { - const query: PageQueryDTO = { + const query: PageQueryInput = { page: 1, limit: 10, sort: 'playCount', - order: false, + order: 'asc', }; const q = 'trending'; const songList: SongPreviewDto[] = Array(10) @@ -615,15 +657,17 @@ describe('SongController', () => { total: 100, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(10); expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should return correct pagination info with search results', async () => { - const query: PageQueryDTO = { page: 3, limit: 20 }; + const query: PageQueryInput = { page: 3, limit: 20 }; const q = 'search'; const songList: SongPreviewDto[] = Array(20) .fill(null) @@ -636,7 +680,10 @@ describe('SongController', () => { total: 250, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); expect(result.page).toBe(3); expect(result.limit).toBe(20); @@ -645,7 +692,7 @@ describe('SongController', () => { }); it('should handle search with no results', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = 'nonexistent song title xyz'; const songList: SongPreviewDto[] = []; @@ -656,28 +703,33 @@ describe('SongController', () => { total: 0, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(0); expect(result.total).toBe(0); }); it('should handle search errors', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = 'test query'; mockSongService.querySongs.mockRejectedValueOnce( new Error('Database error'), ); - await expect(songController.searchSongs(query, q)).rejects.toThrow( - 'Database error', - ); + await expect( + songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto), + ).rejects.toThrow('Database error'); }); it('should handle search with whitespace-only query', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: PageQueryInput = { page: 1, limit: 10 }; const q = ' '; const songList: SongPreviewDto[] = []; @@ -688,9 +740,11 @@ describe('SongController', () => { total: 0, }); - const result = await songController.searchSongs(query, q); + const result = await songController.searchSongs({ + ...query, + q: q ?? '', + } as SongSearchQueryDto); - expect(result).toBeInstanceOf(PageDto); expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); }); @@ -705,7 +759,7 @@ describe('SongController', () => { mockSongService.getSong.mockResolvedValueOnce(song); - const result = await songController.getSong(id, user); + const result = await songController.getSong({ id }, user); expect(result).toEqual(song); expect(songService.getSong).toHaveBeenCalledWith(id, user); @@ -719,7 +773,9 @@ describe('SongController', () => { mockSongService.getSong.mockRejectedValueOnce(new Error('Error')); - await expect(songController.getSong(id, user)).rejects.toThrow('Error'); + await expect(songController.getSong({ id }, user)).rejects.toThrow( + 'Error', + ); }); }); @@ -733,7 +789,7 @@ describe('SongController', () => { mockSongService.getSongEdit.mockResolvedValueOnce(song); - const result = await songController.getEditSong(id, user); + const result = await songController.getEditSong({ id }, user); expect(result).toEqual(song); expect(songService.getSongEdit).toHaveBeenCalledWith(id, user); @@ -747,7 +803,7 @@ describe('SongController', () => { mockSongService.getSongEdit.mockRejectedValueOnce(new Error('Error')); - await expect(songController.getEditSong(id, user)).rejects.toThrow( + await expect(songController.getEditSong({ id }, user)).rejects.toThrow( 'Error', ); }); @@ -764,7 +820,7 @@ describe('SongController', () => { mockSongService.patchSong.mockResolvedValueOnce(response); - const result = await songController.patchSong(id, req, user); + const result = await songController.patchSong({ id }, req, user); expect(result).toEqual(response); expect(songService.patchSong).toHaveBeenCalledWith(id, req.body, user); @@ -779,7 +835,7 @@ describe('SongController', () => { mockSongService.patchSong.mockRejectedValueOnce(new Error('Error')); - await expect(songController.patchSong(id, req, user)).rejects.toThrow( + await expect(songController.patchSong({ id }, req, user)).rejects.toThrow( 'Error', ); }); @@ -801,7 +857,7 @@ describe('SongController', () => { mockSongService.getSongDownloadUrl.mockResolvedValueOnce(downloadUrl); - await songController.getSongFile(id, src, user, res); + await songController.getSongFile({ id }, { src }, user, res); expect(res.set).toHaveBeenCalledWith({ 'Content-Disposition': 'attachment; filename="song.nbs"', @@ -835,7 +891,7 @@ describe('SongController', () => { ); await expect( - songController.getSongFile(id, src, user, res), + songController.getSongFile({ id }, { src }, user, res), ).rejects.toThrow('Error'); }); }); @@ -851,7 +907,9 @@ describe('SongController', () => { mockSongService.getSongDownloadUrl.mockResolvedValueOnce(url); - const result = await songController.getSongOpenUrl(id, user, src); + const result = await songController.getSongOpenUrl({ id }, user, { + src, + }); expect(result).toEqual(url); @@ -871,7 +929,7 @@ describe('SongController', () => { const src = 'invalid-src'; await expect( - songController.getSongOpenUrl(id, user, src), + songController.getSongOpenUrl({ id }, user, { src }), ).rejects.toThrow(UnauthorizedException); }); @@ -887,7 +945,7 @@ describe('SongController', () => { ); await expect( - songController.getSongOpenUrl(id, user, src), + songController.getSongOpenUrl({ id }, user, { src }), ).rejects.toThrow('Error'); }); }); @@ -901,7 +959,7 @@ describe('SongController', () => { mockSongService.deleteSong.mockResolvedValueOnce(undefined); - await songController.deleteSong(id, user); + await songController.deleteSong({ id }, user); expect(songService.deleteSong).toHaveBeenCalledWith(id, user); }); @@ -914,7 +972,7 @@ describe('SongController', () => { mockSongService.deleteSong.mockRejectedValueOnce(new Error('Error')); - await expect(songController.deleteSong(id, user)).rejects.toThrow( + await expect(songController.deleteSong({ id }, user)).rejects.toThrow( 'Error', ); }); diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index 737456fb..6ad1306d 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -1,12 +1,10 @@ import type { RawBodyRequest } from '@nestjs/common'; import { - BadRequestException, Body, Controller, Delete, Get, Headers, - HttpException, HttpStatus, Inject, Logger, @@ -35,22 +33,32 @@ import { import type { Response } from 'express'; import { BROWSER_SONGS, TIMESPANS, UPLOAD_CONSTANTS } from '@nbw/config'; +import type { SongWithUser, UserDocument } from '@nbw/database'; import { - PageQueryDTO, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, - PageDto, - SongListQueryDTO, + createFeaturedSongsDto, + type FeaturedSongsDto, + type PageDto, + type PageQueryInput, + type SongPreviewDto, SongSortType, - FeaturedSongsDto, -} from '@nbw/database'; -import type { SongWithUser, TimespanType, UserDocument } from '@nbw/database'; + type SongViewDto, + type TimespanType, + type UploadSongDto, + type UploadSongResponseDto, +} from '@nbw/validation'; import { FileService } from '@server/file/file.service'; import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; +import { + SongFileQueryDto, + SongIdParamDto, + SongListQueryDto, + SongOpenHeadersDto, + SongSearchQueryDto, + UploadSongBodyDto, +} from '@server/zod-dto'; import { SongService } from './song.service'; +import { songPreviewFromSongDocumentWithUser } from './song.util'; @Controller('song') @ApiTags('song') @@ -94,14 +102,13 @@ export class SongController { @ApiResponse({ status: 200, description: 'Success. Returns paginated list of song previews.', - type: PageDto, }) @ApiResponse({ status: 400, description: 'Bad Request. Invalid query parameters.', }) public async getSongList( - @Query() query: SongListQueryDTO, + @Query() query: SongListQueryDto, ): Promise> { // Handle random sort if (query.sort === SongSortType.RANDOM) { @@ -110,12 +117,13 @@ export class SongController { query.category, ); - return new PageDto({ + return { content: data, page: query.page, limit: query.limit, total: data.length, - }); + order: true, + }; } // Map sort types to MongoDB field paths @@ -130,27 +138,28 @@ export class SongController { const sortField = sortFieldMap.get(query.sort ?? SongSortType.RECENT); const isDescending = query.order ? query.order === 'desc' : true; - // Build PageQueryDTO with the sort field - const pageQuery = new PageQueryDTO({ + const pageQuery: PageQueryInput = { page: query.page, limit: query.limit, - sort: sortField, - order: isDescending, - }); + sort: sortField ?? 'createdAt', + order: isDescending ? 'desc' : 'asc', + }; - // Query songs with optional search and category filters + // Query songs with optional search, category, and uploader filters const result = await this.songService.querySongs( pageQuery, query.q, query.category, + query.uploader, ); - return new PageDto({ + return { content: result.content, page: query.page, limit: query.limit, total: result.total, - }); + order: isDescending, + }; } @Get('/featured') @@ -164,7 +173,6 @@ export class SongController { @ApiResponse({ status: 200, description: 'Success. Returns featured songs data.', - type: FeaturedSongsDto, }) public async getFeaturedSongs(): Promise { const now = new Date(Date.now()); @@ -209,25 +217,25 @@ export class SongController { songs[timespan as TimespanType] = songPage; } - const featuredSongs = FeaturedSongsDto.create(); + const featuredSongs = createFeaturedSongsDto(); featuredSongs.hour = songs.hour.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ); featuredSongs.day = songs.day.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ); featuredSongs.week = songs.week.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ); featuredSongs.month = songs.month.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ); featuredSongs.year = songs.year.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ); featuredSongs.all = songs.all.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ); return featuredSongs; @@ -257,25 +265,26 @@ export class SongController { summary: 'Search songs by keywords with pagination and sorting', }) public async searchSongs( - @Query() query: PageQueryDTO, - @Query('q') q: string, + @Query() query: SongSearchQueryDto, ): Promise> { - const result = await this.songService.querySongs(query, q ?? ''); - return new PageDto({ + const { q: searchQ, ...pageQuery } = query; + const result = await this.songService.querySongs(pageQuery, searchQ ?? ''); + return { content: result.content, - page: query.page, - limit: query.limit, + page: result.page, + limit: result.limit, total: result.total, - }); + order: String(pageQuery.order ?? 'desc') === 'desc', + }; } @Get('/:id') @ApiOperation({ summary: 'Get song info by ID' }) public async getSong( - @Param('id') id: string, + @Param() params: SongIdParamDto, @GetRequestToken() user: UserDocument | null, ): Promise { - return await this.songService.getSong(id, user); + return await this.songService.getSong(params.id, user); } @Get('/:id/edit') @@ -283,38 +292,40 @@ export class SongController { @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() public async getEditSong( - @Param('id') id: string, + @Param() params: SongIdParamDto, @GetRequestToken() user: UserDocument | null, ): Promise { user = validateUser(user); - return await this.songService.getSongEdit(id, user); + return await this.songService.getSongEdit(params.id, user); } @Patch('/:id/edit') @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiOperation({ summary: 'Edit song info by ID' }) - @ApiBody({ description: 'Upload Song', type: UploadSongResponseDto }) + @ApiBody({ description: 'Upload Song' }) public async patchSong( - @Param('id') id: string, + @Param() params: SongIdParamDto, @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; - return await this.songService.patchSong(id, body, user); + return await this.songService.patchSong(params.id, body, user); } @Get('/:id/download') @ApiOperation({ summary: 'Get song .nbs file' }) public async getSongFile( - @Param('id') id: string, - @Query('src') src: string, + @Param() params: SongIdParamDto, + @Query() query: SongFileQueryDto, @GetRequestToken() user: UserDocument | null, @Res() res: Response, ): Promise { user = validateUser(user); + const { src } = query; + const { id } = params; // TODO: no longer used res.set({ @@ -330,22 +341,17 @@ export class SongController { @Get('/:id/open') @ApiOperation({ summary: 'Get song .nbs file' }) public async getSongOpenUrl( - @Param('id') id: string, + @Param() params: SongIdParamDto, @GetRequestToken() user: UserDocument | null, - @Headers('src') src: string, + @Headers() headers: SongOpenHeadersDto, ): Promise { + const { src } = headers; + const { id } = params; if (src != 'downloadButton') { throw new UnauthorizedException('Invalid source'); } - const url = await this.songService.getSongDownloadUrl( - id, - user, - 'open', - true, - ); - - return url; + return await this.songService.getSongDownloadUrl(id, user, 'open', true); } @Delete('/:id') @@ -353,25 +359,25 @@ export class SongController { @ApiBearerAuth() @ApiOperation({ summary: 'Delete a song' }) public async deleteSong( - @Param('id') id: string, + @Param() params: SongIdParamDto, @GetRequestToken() user: UserDocument | null, ): Promise { user = validateUser(user); - await this.songService.deleteSong(id, user); + await this.songService.deleteSong(params.id, user); } @Post('/') @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiConsumes('multipart/form-data') - @ApiBody({ description: 'Upload Song', type: UploadSongResponseDto }) + @ApiBody({ description: 'Upload Song' }) @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, + @Body() body: UploadSongBodyDto, @GetRequestToken() user: UserDocument | null, ): Promise { user = validateUser(user); diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index edf8dfb6..75711592 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1,25 +1,23 @@ -import type { UserDocument } from '@nbw/database'; -import { - SongDocument, - Song as SongEntity, - SongPreviewDto, - SongSchema, - SongStats, - SongViewDto, - SongWithUser, - UploadSongDto, - UploadSongResponseDto, -} from '@nbw/database'; import { HttpException } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import mongoose, { Model } from 'mongoose'; +import { SongDocument, Song as SongEntity, SongWithUser } from '@nbw/database'; +import type { UserDocument } from '@nbw/database'; +import type { SongStats, UploadSongDto } from '@nbw/validation'; import { FileService } from '@server/file/file.service'; +import { UserService } from '@server/user/user.service'; import { SongUploadService } from './song-upload/song-upload.service'; import { SongWebhookService } from './song-webhook/song-webhook.service'; import { SongService } from './song.service'; +import { + songPreviewFromSongDocumentWithUser, + songViewDtoFromSongDocument, + uploadSongDtoFromSongDocument, + uploadSongResponseDtoFromSongWithUser, +} from './song.util'; const mockFileService = { deleteSong: jest.fn(), @@ -40,6 +38,10 @@ const mockSongWebhookService = { syncSongWebhook: jest.fn(), }; +const mockUserService = { + findByUsername: jest.fn(), +}; + const mockSongModel = { create: jest.fn(), findOne: jest.fn(), @@ -79,6 +81,10 @@ describe('SongService', () => { provide: SongUploadService, useValue: mockSongUploadService, }, + { + provide: UserService, + useValue: mockUserService, + }, ], }).compile(); @@ -181,7 +187,7 @@ describe('SongService', () => { const result = await service.uploadSong({ file, user, body }); expect(result).toEqual( - UploadSongResponseDto.fromSongWithUserDocument(populatedSong), + uploadSongResponseDtoFromSongWithUser(populatedSong), ); expect(songUploadService.processUploadedSong).toHaveBeenCalledWith({ @@ -261,7 +267,7 @@ describe('SongService', () => { const result = await service.deleteSong(publicId, user); expect(result).toEqual( - UploadSongResponseDto.fromSongWithUserDocument(populatedSong), + uploadSongResponseDtoFromSongWithUser(populatedSong), ); expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); @@ -393,7 +399,7 @@ describe('SongService', () => { const result = await service.patchSong(publicId, body, user); expect(result).toEqual( - UploadSongResponseDto.fromSongWithUserDocument(populatedSong as any), + uploadSongResponseDtoFromSongWithUser(populatedSong as any), ); expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); @@ -533,7 +539,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'asc' as const, }; const songList: SongWithUser[] = []; @@ -551,7 +557,7 @@ describe('SongService', () => { const result = await service.getSongByPage(query); expect(result).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); @@ -567,25 +573,13 @@ describe('SongService', () => { it('should throw an error if the query is invalid', async () => { const query = { - page: undefined, - limit: undefined, - sort: undefined, - order: true, - }; - - const songList: SongWithUser[] = []; - - const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - populate: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(songList), + page: -1, + limit: 10, + sort: 'createdAt', + order: 'asc' as const, }; - jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); - - expect(service.getSongByPage(query)).rejects.toThrow(HttpException); + await expect(service.getSongByPage(query as any)).rejects.toThrow(); }); }); @@ -625,7 +619,9 @@ describe('SongService', () => { const result = await service.getSong(publicId, user); - expect(result).toEqual(SongViewDto.fromSongDocument(songDocument)); + expect(result).toEqual( + songViewDtoFromSongDocument(songDocument as SongWithUser), + ); expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); }); @@ -851,7 +847,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'asc' as const, }; const user: UserDocument = { _id: 'test-user-id' } as UserDocument; @@ -870,7 +866,7 @@ describe('SongService', () => { expect(result).toEqual({ content: songList.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ), page: 1, limit: 10, @@ -908,7 +904,7 @@ describe('SongService', () => { const result = await service.getSongEdit(publicId, user); - expect(result).toEqual(UploadSongDto.fromSongDocument(songEntity as any)); + expect(result).toEqual(uploadSongDtoFromSongDocument(songEntity as any)); expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); }); @@ -998,7 +994,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'stats.duration', - order: false, + order: 'asc' as const, }; const category = 'pop'; const songList: SongWithUser[] = []; @@ -1014,10 +1010,15 @@ describe('SongService', () => { jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); - const result = await service.querySongs(query, undefined, category); + const result = await service.querySongs( + query, + undefined, + category, + undefined, + ); expect(result.content).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(result.page).toBe(1); expect(result.limit).toBe(10); @@ -1051,7 +1052,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'desc' as const, }; const songList: SongWithUser[] = []; @@ -1066,12 +1067,12 @@ describe('SongService', () => { jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); - const result = await service.querySongs(query); + const result = await service.querySongs(query, undefined, undefined); expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 }); expect(result.content).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(result.total).toBe(0); }); @@ -1081,7 +1082,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'playCount', - order: false, + order: 'asc' as const, }; const searchTerm = 'test song'; const category = 'rock'; @@ -1098,10 +1099,15 @@ describe('SongService', () => { jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); - const result = await service.querySongs(query, searchTerm, category); + const result = await service.querySongs( + query, + searchTerm, + category, + undefined, + ); expect(result.content).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(result.total).toBe(0); @@ -1114,6 +1120,61 @@ describe('SongService', () => { expect(mockFind.sort).toHaveBeenCalledWith({ playCount: 1 }); }); + + it('should filter by uploader username', async () => { + const query = { + page: 1, + limit: 10, + sort: 'createdAt', + order: 'desc' as const, + }; + const uploaderId = new mongoose.Types.ObjectId(); + mockUserService.findByUsername.mockResolvedValue({ + _id: uploaderId, + username: 'artist', + }); + + const songList: SongWithUser[] = []; + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(songList), + }; + + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); + + await service.querySongs(query, undefined, undefined, 'artist'); + + expect(mockUserService.findByUsername).toHaveBeenCalledWith('artist'); + expect(songModel.find).toHaveBeenCalledWith({ + visibility: 'public', + uploader: uploaderId, + }); + }); + + it('should return empty page when uploader username not found', async () => { + const query = { + page: 1, + limit: 10, + sort: 'createdAt', + order: 'desc' as const, + }; + mockUserService.findByUsername.mockResolvedValue(null); + + const result = await service.querySongs( + query, + undefined, + undefined, + 'nobody', + ); + + expect(result.content).toEqual([]); + expect(result.total).toBe(0); + expect(songModel.find).not.toHaveBeenCalled(); + }); }); describe('getSongsForTimespan', () => { @@ -1180,7 +1241,7 @@ describe('SongService', () => { const result = await service.getRandomSongs(count); expect(result).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(mockSongModel.aggregate).toHaveBeenCalledWith([ @@ -1207,7 +1268,7 @@ describe('SongService', () => { const result = await service.getRandomSongs(count, category); expect(result).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(mockSongModel.aggregate).toHaveBeenCalledWith([ diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index 82da03f8..a0b4d61c 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -10,21 +10,32 @@ import { Model } from 'mongoose'; import { BROWSER_SONGS } from '@nbw/config'; import { - UserDocument, - PageQueryDTO, Song as SongEntity, - SongPageDto, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, type SongWithUser, + UserDocument, } from '@nbw/database'; +import { + pageQueryDTOSchema, + type PageQueryInput, + type SongPageDto, + type SongPreviewDto, + type SongViewDto, + type UploadSongDto, + type UploadSongResponseDto, +} from '@nbw/validation'; import { FileService } from '@server/file/file.service'; +import { UserService } from '@server/user/user.service'; import { SongUploadService } from './song-upload/song-upload.service'; import { SongWebhookService } from './song-webhook/song-webhook.service'; -import { removeExtraSpaces } from './song.util'; +import { + removeExtraSpaces, + type SongPreviewSource, + songPreviewFromSongDocumentWithUser, + songViewDtoFromSongDocument, + uploadSongDtoFromSongDocument, + uploadSongResponseDtoFromSongWithUser, +} from './song.util'; @Injectable() export class SongService { @@ -41,6 +52,9 @@ export class SongService { @Inject(SongWebhookService) private songWebhookService: SongWebhookService, + + @Inject(UserService) + private userService: UserService, ) {} public async getSongById(publicId: string) { @@ -82,7 +96,7 @@ export class SongService { // Save song document await songDocument.save(); - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); + return uploadSongResponseDtoFromSongWithUser(populatedSong); } public async deleteSong( @@ -112,7 +126,7 @@ export class SongService { await this.songWebhookService.deleteSongWebhook(populatedSong); - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); + return uploadSongResponseDtoFromSongWithUser(populatedSong); } public async patchSong( @@ -171,20 +185,20 @@ export class SongService { 'username profileImage -_id', )) as unknown as SongWithUser; - const webhookMessageId = await this.songWebhookService.syncSongWebhook( + foundSong.webhookMessageId = await this.songWebhookService.syncSongWebhook( populatedSong, ); - foundSong.webhookMessageId = webhookMessageId; - // Save song document await foundSong.save(); - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); + return uploadSongResponseDtoFromSongWithUser(populatedSong); } - public async getSongByPage(query: PageQueryDTO): Promise { - const { page, limit, sort, order } = query; + public async getSongByPage(query: PageQueryInput): Promise { + const q = pageQueryDTOSchema.parse(query); + const { page, limit, sort } = q; + const ascendingOrder = q.order === 'asc'; if (!page || !limit || !sort) { throw new HttpException( @@ -193,29 +207,34 @@ export class SongService { ); } - const songs = (await this.songModel + const songs = await this.songModel .find({ visibility: 'public', }) .sort({ - [sort]: order ? 1 : -1, + [sort]: ascendingOrder ? 1 : -1, }) .skip(page * limit - limit) .limit(limit) - .populate('uploader', 'username publicName profileImage -_id') - .exec()) as unknown as SongWithUser[]; + .populate<{ uploader: SongPreviewSource['uploader'] }>( + 'uploader', + 'username publicName profileImage -_id', + ) + .exec(); - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + return songs.map((song) => songPreviewFromSongDocumentWithUser(song)); } public async querySongs( - query: PageQueryDTO, + query: PageQueryInput, q?: string, category?: string, + uploaderUsername?: string, ): Promise { - const page = parseInt(query.page?.toString() ?? '1'); - const limit = parseInt(query.limit?.toString() ?? '10'); - const descending = query.order ?? true; + const parsed = pageQueryDTOSchema.parse(query); + const page = parsed.page; + const limit = parsed.limit ?? 10; + const ascendingOrder = parsed.order === 'asc'; const allowedSorts = new Set([ 'createdAt', @@ -224,14 +243,27 @@ export class SongService { 'stats.duration', 'stats.noteCount', ]); - const sortField = allowedSorts.has(query.sort ?? '') - ? (query.sort as string) + const sortField = allowedSorts.has(parsed.sort ?? '') + ? (parsed.sort as string) : 'createdAt'; const mongoQuery: any = { visibility: 'public', }; + if (uploaderUsername) { + const uploader = await this.userService.findByUsername(uploaderUsername); + if (!uploader) { + return { + content: [], + page, + limit, + total: 0, + }; + } + mongoQuery.uploader = uploader._id; + } + // Add category filter if provided if (category) { mongoQuery.category = category; @@ -246,18 +278,17 @@ export class SongService { // Build Google-like search: all words must appear across any of the fields if (terms.length > 0) { - const andClauses = terms.map((word) => ({ + mongoQuery.$and = terms.map((word) => ({ $or: [ { title: { $regex: word, $options: 'i' } }, { originalAuthor: { $regex: word, $options: 'i' } }, { description: { $regex: word, $options: 'i' } }, ], })); - mongoQuery.$and = andClauses; } } - const sortOrder = descending ? -1 : 1; + const sortOrder = ascendingOrder ? 1 : -1; const [songs, total] = await Promise.all([ this.songModel @@ -271,9 +302,7 @@ export class SongService { ]); return { - content: songs.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ), + content: songs.map((song) => songPreviewFromSongDocumentWithUser(song)), page, limit, total, @@ -336,10 +365,12 @@ export class SongService { const populatedSong = await foundSong.populate( 'uploader', - 'username profileImage -_id', + 'username profileImage', ); - return SongViewDto.fromSongDocument(populatedSong); + return songViewDtoFromSongDocument( + populatedSong as unknown as SongWithUser, + ); } // TODO: service should not handle HTTP -> https://www.reddit.com/r/node/comments/uoicw1/should_i_return_status_code_from_service_layer/ @@ -397,20 +428,21 @@ export class SongService { query, user, }: { - query: PageQueryDTO; + query: PageQueryInput; user: UserDocument; }): Promise { - const page = parseInt(query.page?.toString() ?? '1'); - const limit = parseInt(query.limit?.toString() ?? '10'); - const order = query.order ? query.order : false; - const sort = query.sort ? query.sort : 'recent'; + const q = pageQueryDTOSchema.parse(query); + const page = q.page; + const limit = q.limit ?? 10; + const ascendingOrder = q.order === 'asc'; + const sort = q.sort ?? 'recent'; const songData = (await this.songModel .find({ uploader: user._id, }) .sort({ - [sort]: order ? 1 : -1, + [sort]: ascendingOrder ? 1 : -1, }) .skip(limit * (page - 1)) .limit(limit)) as unknown as SongWithUser[]; @@ -421,7 +453,7 @@ export class SongService { return { content: songData.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ), page: page, limit: limit, @@ -445,7 +477,7 @@ export class SongService { throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); } - return UploadSongDto.fromSongDocument(foundSong); + return uploadSongDtoFromSongDocument(foundSong); } public async getCategories(): Promise> { @@ -511,6 +543,6 @@ export class SongService { select: 'username profileImage -_id', }); - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + return songs.map((song) => songPreviewFromSongDocumentWithUser(song)); } } diff --git a/apps/backend/src/song/song.util.ts b/apps/backend/src/song/song.util.ts index 02aadec7..c8cf8f70 100644 --- a/apps/backend/src/song/song.util.ts +++ b/apps/backend/src/song/song.util.ts @@ -1,17 +1,22 @@ import { customAlphabet } from 'nanoid'; import { UPLOAD_CONSTANTS } from '@nbw/config'; -import { SongWithUser } from '@nbw/database'; +import { Song as SongEntity, SongWithUser } from '@nbw/database'; +import type { + SongPreviewDto, + VisibilityType, + SongViewDto, + UploadSongDto, + UploadSongResponseDto, +} from '@nbw/validation'; export const formatDuration = (totalSeconds: number) => { const minutes = Math.floor(Math.ceil(totalSeconds) / 60); const seconds = Math.ceil(totalSeconds) % 60; - const formattedTime = `${minutes.toFixed().padStart(1, '0')}:${seconds + return `${minutes.toFixed().padStart(1, '0')}:${seconds .toFixed() .padStart(2, '0')}`; - - return formattedTime; }; export function removeExtraSpaces(input: string): string { @@ -30,6 +35,116 @@ export const generateSongId = () => { return nanoid(); }; +export function uploadSongResponseDtoFromSongWithUser( + song: SongWithUser, +): UploadSongResponseDto { + const uploaderDoc = song.uploader as SongWithUser['uploader'] & { + _id?: { toString(): string }; + }; + const uploaderId = uploaderDoc._id?.toString() ?? ''; + + return { + publicId: song.publicId, + title: song.title, + uploader: { + id: uploaderId, + username: song.uploader.username, + profileImage: song.uploader.profileImage, + }, + thumbnailUrl: song.thumbnailUrl, + duration: song.stats.duration, + noteCount: song.stats.noteCount, + }; +} + +export function songViewDtoFromSongDocument(song: SongWithUser): SongViewDto { + const uploaderDoc = song.uploader as SongWithUser['uploader'] & { + _id?: { toString(): string }; + }; + const uploaderId = uploaderDoc._id?.toString() ?? ''; + + return { + publicId: song.publicId, + createdAt: song.createdAt, + uploader: { + id: uploaderId, + username: song.uploader.username, + profileImage: song.uploader.profileImage, + }, + thumbnailUrl: song.thumbnailUrl, + playCount: song.playCount, + downloadCount: song.downloadCount, + likeCount: song.likeCount, + allowDownload: song.allowDownload, + title: song.title, + originalAuthor: song.originalAuthor, + description: song.description, + visibility: song.visibility, + category: song.category, + license: song.license, + customInstruments: song.customInstruments, + fileSize: song.fileSize, + stats: song.stats, + }; +} + +export function uploadSongDtoFromSongDocument(song: SongEntity): UploadSongDto { + return { + file: undefined as unknown as Express.Multer.File, + allowDownload: song.allowDownload, + visibility: song.visibility, + title: song.title, + originalAuthor: song.originalAuthor, + description: song.description, + category: song.category, + thumbnailData: song.thumbnailData, + license: song.license, + customInstruments: song.customInstruments, + } as UploadSongDto; +} + +export function songPreviewFromSongDocumentWithUser( + song: SongPreviewSource, +): SongPreviewDto { + return { + publicId: song.publicId, + uploader: { + username: song.uploader.username, + profileImage: song.uploader.profileImage, + }, + title: song.title, + description: song.description ?? '', + originalAuthor: song.originalAuthor ?? '', + duration: song.stats.duration, + noteCount: song.stats.noteCount, + thumbnailUrl: song.thumbnailUrl, + createdAt: song.createdAt, + updatedAt: song.updatedAt, + playCount: song.playCount, + visibility: song.visibility, + }; +} + +export type SongPreviewSource = { + publicId: string; + uploader: { + username: string; + profileImage: string; + }; + title: string; + description: string; + originalAuthor: string; + stats: { + duration: number; + noteCount: number; + }; + thumbnailUrl: string; + createdAt: Date; + updatedAt: Date; + playCount: number; + visibility: VisibilityType; +}; + export function getUploadDiscordEmbed({ title, description, diff --git a/apps/backend/src/user/user.controller.spec.ts b/apps/backend/src/user/user.controller.spec.ts index e512c59b..66d82947 100644 --- a/apps/backend/src/user/user.controller.spec.ts +++ b/apps/backend/src/user/user.controller.spec.ts @@ -1,13 +1,9 @@ -import type { UserDocument } from '@nbw/database'; -import { - GetUser, - PageQueryDTO, - UpdateUsernameDto, - UserDto, -} from '@nbw/database'; -import { HttpException, HttpStatus } from '@nestjs/common'; +import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import type { UserDocument } from '@nbw/database'; +import type { UpdateUsernameDto, UserIndexQuery } from '@nbw/validation'; + import { UserController } from './user.controller'; import { UserService } from './user.service'; @@ -42,64 +38,72 @@ describe('UserController', () => { expect(userController).toBeDefined(); }); - describe('getUser', () => { - it('should return user data by email', async () => { - const query: GetUser = { + describe('getUserIndex', () => { + it('should return paginated users filtered by email', async () => { + const query = { email: 'test@email.com', - }; - - const user = { email: 'test@email.com' }; + page: 1, + limit: 10, + sort: 'createdAt', + order: 'asc', + } satisfies UserIndexQuery; + const usersPage = { users: [{ email: 'test@email.com' }], total: 1 }; - mockUserService.findByEmail.mockResolvedValueOnce(user); + mockUserService.getUserPaginated.mockResolvedValueOnce(usersPage); - const result = await userController.getUser(query); + const result = await userController.getUserIndex(query); - expect(result).toEqual(user); - expect(userService.findByEmail).toHaveBeenCalledWith(query.email); + expect(result).toEqual(usersPage); + expect(userService.getUserPaginated).toHaveBeenCalledWith(query); }); - it('should return user data by ID', async () => { - const query: GetUser = { + it('should return paginated users filtered by id', async () => { + const query = { id: 'test-id', - }; - - const user = { _id: 'test-id' }; + page: 1, + limit: 10, + sort: 'createdAt', + order: 'asc', + } satisfies UserIndexQuery; + const usersPage = { users: [{ _id: 'test-id' }], total: 1 }; - mockUserService.findByID.mockResolvedValueOnce(user); + mockUserService.getUserPaginated.mockResolvedValueOnce(usersPage); - const result = await userController.getUser(query); + const result = await userController.getUserIndex(query); - expect(result).toEqual(user); - expect(userService.findByID).toHaveBeenCalledWith(query.id); + expect(result).toEqual(usersPage); + expect(userService.getUserPaginated).toHaveBeenCalledWith(query); }); - it('should throw an error if username is provided', async () => { - const query: GetUser = { + it('should return paginated users filtered by username', async () => { + const query = { username: 'test-username', - }; - - await expect(userController.getUser(query)).rejects.toThrow( - HttpException, - ); - }); + page: 1, + limit: 10, + sort: 'createdAt', + order: 'asc', + } satisfies UserIndexQuery; + const usersPage = { users: [{ username: 'test-username' }], total: 1 }; - it('should throw an error if neither email nor ID is provided', async () => { - const query: GetUser = {}; + mockUserService.getUserPaginated.mockResolvedValueOnce(usersPage); - await expect(userController.getUser(query)).rejects.toThrow( - HttpException, - ); + const result = await userController.getUserIndex(query); + expect(result).toEqual(usersPage); + expect(userService.getUserPaginated).toHaveBeenCalledWith(query); }); - }); - describe('getUserPaginated', () => { it('should return paginated user data', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query = { + page: 1, + limit: 10, + sort: 'createdAt', + order: 'asc', + } satisfies UserIndexQuery; const paginatedUsers = { users: [], total: 0, page: 1, limit: 10 }; mockUserService.getUserPaginated.mockResolvedValueOnce(paginatedUsers); - const result = await userController.getUserPaginated(query); + const result = await userController.getUserIndex(query); expect(result).toEqual(paginatedUsers); expect(userService.getUserPaginated).toHaveBeenCalledWith(query); @@ -243,6 +247,8 @@ describe('UserController', () => { const user: UserDocument = { _id: 'test-user-id', username: 'olduser', + publicName: 'old', + email: 'old@example.com', save: jest.fn().mockResolvedValue(true), } as unknown as UserDocument; const body: UpdateUsernameDto = { username: 'newuser' }; @@ -251,13 +257,6 @@ describe('UserController', () => { mockUserService.normalizeUsername.mockReturnValue(normalizedUsername); mockUserService.usernameExists.mockResolvedValue(false); - // Mock UserDto.fromEntity - jest.spyOn(UserDto, 'fromEntity').mockReturnValue({ - username: normalizedUsername, - publicName: user.publicName, - email: user.email, - }); - const result = await userController.updateUsername(user, body); expect(user.username).toBe(normalizedUsername); diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts index 60d4f4b0..ec8ac0af 100644 --- a/apps/backend/src/user/user.controller.ts +++ b/apps/backend/src/user/user.controller.ts @@ -11,14 +11,11 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { UserDocument } from '@nbw/database'; -import { - GetUser, - PageQueryDTO, - UpdateUsernameDto, - UserDto, -} from '@nbw/database'; +import type { UserDto, UserIndexPageQueryInput } from '@nbw/validation'; import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; +import { UpdateUsernameBodyDto } from '../zod-dto'; + import { UserService } from './user.service'; @Controller('user') @@ -31,34 +28,7 @@ export class UserController { @Get() @ApiTags('user') @ApiBearerAuth() - async getUser(@Query() query: GetUser) { - const { email, id, username } = query; - - if (email) { - return await this.userService.findByEmail(email); - } - - if (id) { - return await this.userService.findByID(id); - } - - if (username) { - throw new HttpException( - 'Username is not supported yet', - HttpStatus.BAD_REQUEST, - ); - } - - throw new HttpException( - 'You must provide an email or an id', - HttpStatus.BAD_REQUEST, - ); - } - - @Get() - @ApiTags('user') - @ApiBearerAuth() - async getUserPaginated(@Query() query: PageQueryDTO) { + async getUserIndex(@Query() query: UserIndexPageQueryInput) { return await this.userService.getUserPaginated(query); } @@ -106,7 +76,7 @@ export class UserController { @ApiOperation({ summary: 'Update the username' }) async updateUsername( @GetRequestToken() user: UserDocument | null, - @Body() body: UpdateUsernameDto, + @Body() body: UpdateUsernameBodyDto, ) { user = validateUser(user); let { username } = body; @@ -128,6 +98,11 @@ export class UserController { await user.save(); - return UserDto.fromEntity(user); + const dto: UserDto = { + username: user.username, + publicName: user.publicName, + email: user.email, + }; + return dto; } } diff --git a/apps/backend/src/user/user.service.spec.ts b/apps/backend/src/user/user.service.spec.ts index 6cd41e74..0d33fa85 100644 --- a/apps/backend/src/user/user.service.spec.ts +++ b/apps/backend/src/user/user.service.spec.ts @@ -1,9 +1,10 @@ -import { CreateUser, PageQueryDTO, User, UserDocument } from '@nbw/database'; -import { HttpException, HttpStatus } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { Model } from 'mongoose'; +import { User, UserDocument } from '@nbw/database'; +import { type CreateUser, type UserIndexPageQueryInput } from '@nbw/validation'; + import { UserService } from './user.service'; const mockUserModel = { @@ -45,7 +46,7 @@ describe('UserService', () => { const createUserDto: CreateUser = { username: 'testuser', email: 'test@example.com', - profileImage: 'testimage.png', + profileImage: 'https://example.com/testimage.png', }; const user = { @@ -97,7 +98,12 @@ describe('UserService', () => { describe('getUserPaginated', () => { it('should return paginated users', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + const query: UserIndexPageQueryInput = { + page: 1, + limit: 10, + sort: 'createdAt', + order: 'asc', + }; const users = [{ username: 'testuser' }] as UserDocument[]; const usersPage = { @@ -121,6 +127,34 @@ describe('UserService', () => { expect(result).toEqual(usersPage); expect(userModel.find).toHaveBeenCalledWith({}); }); + + it('should apply email filter when provided', async () => { + const query: UserIndexPageQueryInput = { + page: 1, + limit: 10, + sort: 'createdAt', + order: 'asc', + email: 'test@example.com', + }; + const users = [{ email: 'test@example.com' }] as UserDocument[]; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(users), + }; + + jest.spyOn(userModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(userModel, 'countDocuments').mockResolvedValue(1); + + const result = await service.getUserPaginated(query); + + expect(result.users).toEqual(users); + expect(userModel.find).toHaveBeenCalledWith({ email: query.email }); + expect(userModel.countDocuments).toHaveBeenCalledWith({ + email: query.email, + }); + }); }); describe('getHydratedUser', () => { diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts index a53e2ea5..6708ef2d 100644 --- a/apps/backend/src/user/user.service.ts +++ b/apps/backend/src/user/user.service.ts @@ -1,16 +1,21 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { validate } from 'class-validator'; import { Model } from 'mongoose'; -import { CreateUser, PageQueryDTO, User, UserDocument } from '@nbw/database'; +import { User, UserDocument } from '@nbw/database'; +import { + type CreateUser, + createUserSchema, + userIndexQuerySchema, + type UserIndexPageQueryInput, +} from '@nbw/validation'; @Injectable() export class UserService { constructor(@InjectModel(User.name) private userModel: Model) {} public async create(user_registered: CreateUser) { - await validate(user_registered); + createUserSchema.parse(user_registered); const user = await this.userModel.create(user_registered); user.username = user_registered.username; user.email = user_registered.email; @@ -48,54 +53,54 @@ export class UserService { email.split('@')[0], ); - const user = await this.userModel.create({ + return await this.userModel.create({ email: email, username: emailPrefixUsername, publicName: emailPrefixUsername, }); - - return user; } public async findByEmail(email: string): Promise { - const user = await this.userModel.findOne({ email }).exec(); - - return user; + return await this.userModel.findOne({ email }).exec(); } public async findByID(objectID: string): Promise { - const user = await this.userModel.findById(objectID).exec(); - - return user; + return await this.userModel.findById(objectID).exec(); } public async findByPublicName( publicName: string, ): Promise { - const user = await this.userModel.findOne({ publicName }); - - return user; + return await this.userModel.findOne({ publicName }); } public async findByUsername(username: string): Promise { - const user = await this.userModel.findOne({ username }); - - return user; + return await this.userModel.findOne({ username }); } - public async getUserPaginated(query: PageQueryDTO) { - const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; + public async getUserPaginated(query: UserIndexPageQueryInput) { + const q = userIndexQuerySchema.parse(query); + const page = q.page; + const limit = q.limit ?? 10; + const sort = q.sort; + const normalizedOrder = q.order === 'asc'; + const { email, id, username } = q; const skip = (page - 1) * limit; - const sortOrder = order === 'asc' ? 1 : -1; + const sortOrder = normalizedOrder ? 1 : -1; + const mongoQuery: Record = {}; + + if (email) mongoQuery.email = email; + if (id) mongoQuery._id = id; + if (username) mongoQuery.username = username; const users = await this.userModel - .find({}) + .find(mongoQuery) .sort({ [sort]: sortOrder }) .skip(skip) .limit(limit); - const total = await this.userModel.countDocuments(); + const total = await this.userModel.countDocuments(mongoQuery); return { users, @@ -106,12 +111,7 @@ export class UserService { } public async getHydratedUser(user: UserDocument) { - const hydratedUser = await this.userModel - .findById(user._id) - .populate('songs') - .exec(); - - return hydratedUser; + return await this.userModel.findById(user._id).populate('songs').exec(); } public async usernameExists(username: string) { diff --git a/apps/backend/src/zod-dto/index.ts b/apps/backend/src/zod-dto/index.ts new file mode 100644 index 00000000..32c2dd19 --- /dev/null +++ b/apps/backend/src/zod-dto/index.ts @@ -0,0 +1,15 @@ +export { PatchProfileBodyDto } from './patch-profile.body.dto'; +export { ProfileUsernameParamDto } from './profile-username.param.dto'; +export { PageQueryDto } from './page-query.dto'; +export { SongIdParamDto } from './song-id.param.dto'; +export { SongFileQueryDto } from './song-file.query.dto'; +export { SongOpenHeadersDto } from './song-open.headers.dto'; +export { SongListQueryDto } from './song-list.query.dto'; +export { SongSearchQueryDto } from './song-search.query.dto'; +export { UpdateUsernameBodyDto } from './update-username.body.dto'; +export { UserIdParamDto } from './user-id.param.dto'; +export { UploadSongBodyDto } from './upload-song.body.dto'; +export { + userIndexQuerySchema, + UserIndexQueryDto, +} from './user-index.query.dto'; diff --git a/apps/backend/src/zod-dto/page-query.dto.ts b/apps/backend/src/zod-dto/page-query.dto.ts new file mode 100644 index 00000000..3ded4003 --- /dev/null +++ b/apps/backend/src/zod-dto/page-query.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; + +import { pageQueryDTOSchema } from '@nbw/validation'; + +export class PageQueryDto extends createZodDto(pageQueryDTOSchema) {} diff --git a/apps/backend/src/zod-dto/patch-profile.body.dto.ts b/apps/backend/src/zod-dto/patch-profile.body.dto.ts new file mode 100644 index 00000000..08d327b9 --- /dev/null +++ b/apps/backend/src/zod-dto/patch-profile.body.dto.ts @@ -0,0 +1,19 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +import { patchProfileBodySchema } from '@nbw/validation'; + +/** + * Re-declare `publicName` here so request validation always allows it even when the + * running process resolves an older `@nbw/validation` build (strict object would + * otherwise reject the key with `unrecognized_keys`). + */ +export const patchProfileBodyDtoSchema = patchProfileBodySchema + .extend({ + publicName: z.string().trim().min(1).max(100).optional(), + }) + .strict(); + +export class PatchProfileBodyDto extends createZodDto( + patchProfileBodyDtoSchema, +) {} diff --git a/apps/backend/src/zod-dto/profile-username.param.dto.ts b/apps/backend/src/zod-dto/profile-username.param.dto.ts new file mode 100644 index 00000000..4dcebec2 --- /dev/null +++ b/apps/backend/src/zod-dto/profile-username.param.dto.ts @@ -0,0 +1,7 @@ +import { createZodDto } from 'nestjs-zod'; + +import { profileUsernameParamSchema } from '@nbw/validation'; + +export class ProfileUsernameParamDto extends createZodDto( + profileUsernameParamSchema, +) {} diff --git a/apps/backend/src/zod-dto/song-file.query.dto.ts b/apps/backend/src/zod-dto/song-file.query.dto.ts new file mode 100644 index 00000000..b7d90028 --- /dev/null +++ b/apps/backend/src/zod-dto/song-file.query.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; + +import { songFileQueryDTOSchema } from '@nbw/validation'; + +export class SongFileQueryDto extends createZodDto(songFileQueryDTOSchema) {} diff --git a/apps/backend/src/zod-dto/song-id.param.dto.ts b/apps/backend/src/zod-dto/song-id.param.dto.ts new file mode 100644 index 00000000..90a14894 --- /dev/null +++ b/apps/backend/src/zod-dto/song-id.param.dto.ts @@ -0,0 +1,8 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const songIdParamSchema = z.object({ + id: z.string(), +}); + +export class SongIdParamDto extends createZodDto(songIdParamSchema) {} diff --git a/apps/backend/src/zod-dto/song-list.query.dto.ts b/apps/backend/src/zod-dto/song-list.query.dto.ts new file mode 100644 index 00000000..48e1eb37 --- /dev/null +++ b/apps/backend/src/zod-dto/song-list.query.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; + +import { songListQueryDTOSchema } from '@nbw/validation'; + +export class SongListQueryDto extends createZodDto(songListQueryDTOSchema) {} diff --git a/apps/backend/src/zod-dto/song-open.headers.dto.ts b/apps/backend/src/zod-dto/song-open.headers.dto.ts new file mode 100644 index 00000000..92c79761 --- /dev/null +++ b/apps/backend/src/zod-dto/song-open.headers.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +/** Headers for `GET /song/:id/open` */ +const songOpenHeadersSchema = z.object({ + src: z.string(), +}); + +export class SongOpenHeadersDto extends createZodDto(songOpenHeadersSchema) {} diff --git a/apps/backend/src/zod-dto/song-search.query.dto.ts b/apps/backend/src/zod-dto/song-search.query.dto.ts new file mode 100644 index 00000000..b03329b2 --- /dev/null +++ b/apps/backend/src/zod-dto/song-search.query.dto.ts @@ -0,0 +1,7 @@ +import { createZodDto } from 'nestjs-zod'; + +import { songSearchQueryDTOSchema } from '@nbw/validation'; + +export class SongSearchQueryDto extends createZodDto( + songSearchQueryDTOSchema, +) {} diff --git a/apps/backend/src/zod-dto/update-username.body.dto.ts b/apps/backend/src/zod-dto/update-username.body.dto.ts new file mode 100644 index 00000000..3560f612 --- /dev/null +++ b/apps/backend/src/zod-dto/update-username.body.dto.ts @@ -0,0 +1,7 @@ +import { createZodDto } from 'nestjs-zod'; + +import { updateUsernameDtoSchema } from '@nbw/validation'; + +export class UpdateUsernameBodyDto extends createZodDto( + updateUsernameDtoSchema, +) {} diff --git a/apps/backend/src/zod-dto/upload-song.body.dto.ts b/apps/backend/src/zod-dto/upload-song.body.dto.ts new file mode 100644 index 00000000..e28032f9 --- /dev/null +++ b/apps/backend/src/zod-dto/upload-song.body.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; + +import { uploadSongDtoSchema } from '@nbw/validation'; + +export class UploadSongBodyDto extends createZodDto(uploadSongDtoSchema) {} diff --git a/apps/backend/src/zod-dto/user-id.param.dto.ts b/apps/backend/src/zod-dto/user-id.param.dto.ts new file mode 100644 index 00000000..6e9aae33 --- /dev/null +++ b/apps/backend/src/zod-dto/user-id.param.dto.ts @@ -0,0 +1,12 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +import { isValidObjectId } from 'mongoose'; + +const userIdParamSchema = z.object({ + userId: z + .string() + .refine((id) => isValidObjectId(id), { message: 'Invalid user id' }), +}); + +export class UserIdParamDto extends createZodDto(userIdParamSchema) {} diff --git a/apps/backend/src/zod-dto/user-index.query.dto.ts b/apps/backend/src/zod-dto/user-index.query.dto.ts new file mode 100644 index 00000000..30c46deb --- /dev/null +++ b/apps/backend/src/zod-dto/user-index.query.dto.ts @@ -0,0 +1,6 @@ +import { createZodDto } from 'nestjs-zod'; + +import { userIndexQuerySchema } from '@nbw/validation'; + +export class UserIndexQueryDto extends createZodDto(userIndexQuerySchema) {} +export { userIndexQuerySchema }; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f1e4c2a1..af60b8d9 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,12 +18,13 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@nbw/config": "workspace:*", - "@nbw/database": "workspace:*", "@nbw/song": "workspace:*", "@nbw/thumbnail": "workspace:*", + "@nbw/validation": "workspace:*", "@next/mdx": "^16.0.8", "@next/third-parties": "^16.0.8", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", diff --git a/apps/frontend/src/app/(content)/page.tsx b/apps/frontend/src/app/(content)/page.tsx index 6447f2ea..a9abf1ba 100644 --- a/apps/frontend/src/app/(content)/page.tsx +++ b/apps/frontend/src/app/(content)/page.tsx @@ -1,6 +1,10 @@ import { Metadata } from 'next'; -import type { FeaturedSongsDto, PageDto, SongPreviewDto } from '@nbw/database'; +import type { + FeaturedSongsDto, + PageDto, + SongPreviewDto, +} from '@nbw/validation'; import axiosInstance from '@web/lib/axios'; import { HomePageProvider } from '@web/modules/browse/components/client/context/HomePage.context'; import { HomePageComponent } from '@web/modules/browse/components/HomePageComponent'; diff --git a/apps/frontend/src/app/(content)/song/[id]/page.tsx b/apps/frontend/src/app/(content)/song/[id]/page.tsx index 75a489d8..fd246796 100644 --- a/apps/frontend/src/app/(content)/song/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/song/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { cookies } from 'next/headers'; -import type { SongViewDtoType } from '@nbw/database'; +import type { SongViewDto } from '@nbw/validation'; import axios from '@web/lib/axios'; import { SongPage } from '@web/modules/song/components/SongPage'; @@ -28,7 +28,7 @@ export async function generateMetadata({ } try { - const response = await axios.get(`/song/${id}`, { + const response = await axios.get(`/song/${id}`, { headers, }); diff --git a/apps/frontend/src/app/(content)/user/[id]/page_disable.tsx b/apps/frontend/src/app/(content)/user/[id]/page_disable.tsx deleted file mode 100644 index 01ca89c7..00000000 --- a/apps/frontend/src/app/(content)/user/[id]/page_disable.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import UserProfile from '@web/modules/user/components/UserProfile'; -import { getUserProfileData } from '@web/modules/user/features/user.util'; - -import Layout from '../../layout'; - -const UserPage = async ({ params }: { params: { id: string } }) => { - const { id } = params; - - try { - const userData = await getUserProfileData(id); - - return ( -
- - - -
- ); - } catch { - return ( -
- -

Failed to get user data

-
-
- ); - } -}; - -export default UserPage; diff --git a/apps/frontend/src/app/(content)/user/[username]/page.tsx b/apps/frontend/src/app/(content)/user/[username]/page.tsx new file mode 100644 index 00000000..e3de42dc --- /dev/null +++ b/apps/frontend/src/app/(content)/user/[username]/page.tsx @@ -0,0 +1,152 @@ +import type { Metadata } from 'next'; +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import removeMarkdown from 'remove-markdown'; + +import { getViewerUserId } from '@web/modules/auth/features/auth.utils'; +import SongCard from '@web/modules/browse/components/SongCard'; +import { ProfileBioEditor } from '@web/modules/user/components/client/ProfileBioEditor'; +import { ProfilePublicNameEditor } from '@web/modules/user/components/client/ProfilePublicNameEditor'; +import { + fetchPublicProfileByUsername, + fetchUserSongsByUploader, +} from '@web/modules/user/features/profile.util'; + +const SONGS_PAGE_SIZE = 10; + +type PageProps = { + params: Promise<{ username: string }>; + searchParams: Promise<{ page?: string }>; +}; + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { username } = await params; + try { + const profile = await fetchPublicProfileByUsername(username); + const plain = removeMarkdown(profile.description || '').slice(0, 160); + return { + title: `${profile.publicName} (@${profile.username})`, + description: plain || `Profile of ${profile.publicName}`, + }; + } catch { + return { title: 'Profile' }; + } +} + +const UserProfilePage = async ({ params, searchParams }: PageProps) => { + const { username } = await params; + const sp = await searchParams; + const page = Math.max(1, parseInt(sp.page ?? '1', 10) || 1); + + let profile; + try { + profile = await fetchPublicProfileByUsername(username); + } catch { + notFound(); + } + + const [viewerId, songsPage] = await Promise.all([ + getViewerUserId(), + fetchUserSongsByUploader(profile.username, page, SONGS_PAGE_SIZE), + ]); + + const isOwner = viewerId !== null && viewerId === profile.id; + const totalPages = Math.max(1, Math.ceil(songsPage.total / songsPage.limit)); + const hasSongs = songsPage.total > 0; + const socialEntries = Object.entries(profile.socialLinks ?? {}).filter( + ([, href]) => Boolean(href), + ); + + return ( +
+
+ +
+ +

@{profile.username}

+ {socialEntries.length > 0 && ( +
    + {socialEntries.map(([key, href]) => ( +
  • + + {key} + +
  • + ))} +
+ )} +
+
+ + + + {(hasSongs || isOwner) && ( +
+

Songs

+ {hasSongs ? ( + <> +
+ {songsPage.content.map((song) => ( + + ))} +
+ {totalPages > 1 && ( + + )} + + ) : ( +
+

No public songs yet.

+ + Upload a song + +
+ )} +
+ )} +
+ ); +}; + +export default UserProfilePage; diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 9e25cfbf..6e892ddf 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -98,8 +98,10 @@ export default function RootLayout({ /> + {/* suppressHydrationWarning: browser extensions (e.g. Grammarly) mutate attrs before hydrate */} { } }; +/** Returns the logged-in user id from the session cookie, or null if absent/invalid. */ +export async function getViewerUserId(): Promise { + const token = await getTokenServer(); + if (!token?.value) return null; + try { + const res = await axiosInstance.get('/user/me', { + headers: { + authorization: `Bearer ${token.value}`, + }, + }); + return (res.data as LoggedUserData).id; + } catch { + return null; + } +} + export const getUserData = async (): Promise => { // get token from cookies const token = await getTokenServer(); diff --git a/apps/frontend/src/modules/browse/components/SongCard.tsx b/apps/frontend/src/modules/browse/components/SongCard.tsx index a47c41c0..ff469964 100644 --- a/apps/frontend/src/modules/browse/components/SongCard.tsx +++ b/apps/frontend/src/modules/browse/components/SongCard.tsx @@ -5,12 +5,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; import Skeleton from 'react-loading-skeleton'; -import type { SongPreviewDtoType } from '@nbw/database'; +import type { SongPreviewDto } from '@nbw/validation'; import { formatDuration, formatTimeAgo } from '@web/modules/shared/util/format'; import SongThumbnail from '../../shared/components/layout/SongThumbnail'; -const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { +const SongDataDisplay = ({ song }: { song: SongPreviewDto | null }) => { return (
{/* Song image */} @@ -66,7 +66,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { ); }; -const SongCard = ({ song }: { song: SongPreviewDtoType | null }) => { +const SongCard = ({ song }: { song: SongPreviewDto | null }) => { return !song ? ( ) : ( diff --git a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx index b91a5572..3c4c39f3 100644 --- a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx +++ b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx @@ -1,6 +1,6 @@ 'use client'; import { UPLOAD_CONSTANTS } from '@nbw/config'; -import type { CategoryType } from '@nbw/database'; +import type { CategoryType } from '@nbw/validation'; import { Carousel, CarouselContent, 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 f9dda42b..24bfd218 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 @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { create } from 'zustand'; import { TIMESPANS } from '@nbw/config'; -import type { FeaturedSongsDto, SongPreviewDto } from '@nbw/database'; +import type { FeaturedSongsDto, SongPreviewDto } from '@nbw/validation'; type TimespanType = (typeof TIMESPANS)[number]; diff --git a/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx b/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx index fa0ec87b..9e8231d3 100644 --- a/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { FeaturedSongsDtoType, SongPreviewDtoType } from '@nbw/database'; +import type { FeaturedSongsDto, SongPreviewDto } from '@nbw/validation'; import { FeaturedSongsProvider, @@ -30,8 +30,8 @@ export function HomePageProvider({ initialFeaturedSongs, }: { children: React.ReactNode; - initialRecentSongs: SongPreviewDtoType[]; - initialFeaturedSongs: FeaturedSongsDtoType; + initialRecentSongs: SongPreviewDto[]; + initialFeaturedSongs: FeaturedSongsDto; }) { return ( 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 60a10135..aae6e30d 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 @@ -3,11 +3,11 @@ import { useEffect } from 'react'; import { create } from 'zustand'; -import type { PageDto, SongPreviewDtoType } from '@nbw/database'; +import type { PageDto, SongPreviewDto } from '@nbw/validation'; import axiosInstance from '@web/lib/axios'; interface RecentSongsState { - recentSongs: (SongPreviewDtoType | null | undefined)[]; + recentSongs: (SongPreviewDto | null | undefined)[]; recentError: string; isLoading: boolean; hasMore: boolean; @@ -17,7 +17,7 @@ interface RecentSongsState { } interface RecentSongsActions { - initialize: (initialRecentSongs: SongPreviewDtoType[]) => void; + initialize: (initialRecentSongs: SongPreviewDto[]) => void; setSelectedCategory: (category: string) => void; increasePageRecent: () => Promise; fetchRecentSongs: () => Promise; @@ -31,9 +31,9 @@ const pageSize = 12; const fetchCount = pageSize - adCount; function injectAdSlots( - songs: SongPreviewDtoType[], -): Array { - const songsWithAds: Array = [...songs]; + songs: SongPreviewDto[], +): Array { + const songsWithAds: Array = [...songs]; for (let i = 0; i < adCount; i++) { const adPosition = Math.floor(Math.random() * (songsWithAds.length + 1)); @@ -90,7 +90,7 @@ export const useRecentSongsStore = create((set, get) => ({ params.category = selectedCategory; } - const response = await axiosInstance.get>( + const response = await axiosInstance.get>( '/song', { params }, ); @@ -177,7 +177,7 @@ export const useRecentSongsProvider = () => { // Provider component for initialization (now just a wrapper) type RecentSongsProviderProps = { children: React.ReactNode; - initialRecentSongs: SongPreviewDtoType[]; + initialRecentSongs: SongPreviewDto[]; }; export function RecentSongsProvider({ diff --git a/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx b/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx index 014fc1c7..5d463a39 100644 --- a/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx +++ b/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx @@ -1,5 +1,5 @@ import { MY_SONGS } from '@nbw/config'; -import type { SongPageDtoType, SongsFolder } from '@nbw/database'; +import type { SongPageDto, SongsFolder } from '@nbw/validation'; import axiosInstance from '@web/lib/axios'; import { getTokenServer } from '../../auth/features/auth.utils'; @@ -11,7 +11,7 @@ async function fetchSongsPage( page: number, pageSize: number, token: string, -): Promise { +): Promise { const response = await axiosInstance .get('/my-songs', { headers: { @@ -21,7 +21,7 @@ async function fetchSongsPage( page: page + 1, limit: pageSize, sort: 'createdAt', - order: false, + order: 'desc', }, }) .then((res) => { 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 e57fe8d3..31b19592 100644 --- a/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx @@ -9,7 +9,7 @@ import Link from 'next/link'; import Skeleton from 'react-loading-skeleton'; import { MY_SONGS } from '@nbw/config'; -import type { SongPageDtoType, SongPreviewDtoType } from '@nbw/database'; +import type { SongPageDto, SongPreviewDto } from '@nbw/validation'; import { ErrorBox } from '@web/modules/shared/components/client/ErrorBox'; import { @@ -45,14 +45,14 @@ const SongRows = ({ page, pageSize, }: { - page: SongPageDtoType | null; + page: SongPageDto | null; pageSize: number; }) => { const maxPage = MY_SONGS.PAGE_SIZE; const content = !page ? Array(pageSize).fill(null) - : (page.content as SongPreviewDtoType[]); + : (page.content as SongPreviewDto[]); return ( <> 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 a1993338..5ffe035f 100644 --- a/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx @@ -8,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; import Skeleton from 'react-loading-skeleton'; -import type { SongPreviewDtoType } from '@nbw/database'; +import type { SongPreviewDto } from '@nbw/validation'; import SongThumbnail from '@web/modules/shared/components/layout/SongThumbnail'; import { formatDuration } from '@web/modules/shared/util/format'; @@ -20,7 +20,7 @@ import { import { useMySongsProvider } from './context/MySongs.context'; -export const SongRow = ({ song }: { song?: SongPreviewDtoType | null }) => { +export const SongRow = ({ song }: { song?: SongPreviewDto | null }) => { const { setIsDeleteDialogOpen, setSongToDelete } = useMySongsProvider(); const onDeleteClicked = () => { 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 a2db02d6..7773bcdb 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 @@ -5,17 +5,13 @@ import { toast } from 'react-hot-toast'; import { create } from 'zustand'; import { MY_SONGS } from '@nbw/config'; -import type { - SongPageDtoType, - SongPreviewDtoType, - SongsFolder, -} from '@nbw/database'; +import type { SongPageDto, SongPreviewDto, SongsFolder } from '@nbw/validation'; import axiosInstance from '@web/lib/axios'; import { getTokenLocal } from '@web/lib/axios/token.utils'; interface MySongsState { loadedSongs: SongsFolder; - page: SongPageDtoType | null; + page: SongPageDto | null; totalSongs: number; totalPages: number; currentPage: number; @@ -23,7 +19,7 @@ interface MySongsState { isLoading: boolean; error: string | null; isDeleteDialogOpen: boolean; - songToDelete: SongPreviewDtoType | null; + songToDelete: SongPreviewDto | null; } interface MySongsActions { @@ -39,7 +35,7 @@ interface MySongsActions { nextpage: () => void; prevpage: () => void; setIsDeleteDialogOpen: (isOpen: boolean) => void; - setSongToDelete: (song: SongPreviewDtoType) => void; + setSongToDelete: (song: SongPreviewDto) => void; deleteSong: () => Promise; } @@ -95,7 +91,7 @@ export const useMySongsStore = create((set, get) => ({ }, }); - const data = response.data as SongPageDtoType; + const data = response.data as SongPageDto; // TODO: total, page and pageSize are stored in every page, when it should be stored in the folder (what matters is 'content') set((state) => ({ diff --git a/apps/frontend/src/modules/shared/components/ProfileBioMarkdown.tsx b/apps/frontend/src/modules/shared/components/ProfileBioMarkdown.tsx new file mode 100644 index 00000000..e0587e08 --- /dev/null +++ b/apps/frontend/src/modules/shared/components/ProfileBioMarkdown.tsx @@ -0,0 +1,193 @@ +import Link from 'next/link'; +import type { JSX } from 'react'; +import Markdown, { ExtraProps } from 'react-markdown'; + +/** Compact profile bio markdown: markdown `#` renders as <h3>, never as a page-level <h1>. */ +export function ProfileBioMarkdown({ + MarkdownContent, +}: { + MarkdownContent: string; +}) { + return ( + + {MarkdownContent} + + ); +} + +const bioP = ({ node, ...props }: JSX.IntrinsicElements['p'] & ExtraProps) => ( +

+); + +/** Markdown `#` → always <h3> (subsection of the About block, never page title). */ +const bioH1 = ({ + node, + ...props +}: JSX.IntrinsicElements['h1'] & ExtraProps) => ( +

+); + +const bioH2 = ({ + node, + ...props +}: JSX.IntrinsicElements['h2'] & ExtraProps) => ( +

+); + +const bioH3 = ({ + node, + ...props +}: JSX.IntrinsicElements['h3'] & ExtraProps) => ( +

+); + +const bioH4 = ({ + node, + ...props +}: JSX.IntrinsicElements['h4'] & ExtraProps) => ( +
+); + +const bioH5 = ({ + node, + ...props +}: JSX.IntrinsicElements['h5'] & ExtraProps) => ( +

+); + +const bioH6 = ({ + node, + ...props +}: JSX.IntrinsicElements['h6'] & ExtraProps) => ( +

+); + +const bioHr = ({ + node, + ...props +}: JSX.IntrinsicElements['hr'] & ExtraProps) => ( +


+); + +const bioUl = ({ + node, + ...props +}: JSX.IntrinsicElements['ul'] & ExtraProps) => ( +
    +); + +const bioOl = ({ + node, + ...props +}: JSX.IntrinsicElements['ol'] & ExtraProps) => ( +
      +); + +const bioLi = ({ + node, + ...props +}: JSX.IntrinsicElements['li'] & ExtraProps) => ( +
    1. +); + +const bioBlockquote = ({ + node, + ...props +}: JSX.IntrinsicElements['blockquote'] & ExtraProps) => ( +
      +); + +const bioPre = ({ + node, + ...props +}: JSX.IntrinsicElements['pre'] & ExtraProps) => ( +
      +);
      +
      +const bioCode = ({
      +  node,
      +  ...props
      +}: JSX.IntrinsicElements['code'] & ExtraProps) => (
      +  
      +);
      +
      +const bioA = ({
      +  node,
      +  children,
      +  href = '',
      +  ...props
      +}: JSX.IntrinsicElements['a'] & ExtraProps) => {
      +  const { ref, ...rest } = props;
      +  return (
      +    
      +      {children}
      +    
      +  );
      +};
      +
      +const bioImg = ({
      +  node,
      +  alt,
      +  src = '',
      +  ...props
      +}: JSX.IntrinsicElements['img'] & ExtraProps) => (
      +  {alt}
      +);
      diff --git a/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx b/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx
      index 9d82e188..968a994d 100644
      --- a/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx
      +++ b/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx
      @@ -4,7 +4,7 @@ import { faDice } from '@fortawesome/free-solid-svg-icons';
       import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
       import { useRouter } from 'next/navigation';
       
      -import type { PageDto, SongPreviewDto } from '@nbw/database';
      +import type { PageDto, SongPreviewDto } from '@nbw/validation';
       import axios from '@web/lib/axios';
       
       import { MusicalNote } from './MusicalNote';
      diff --git a/apps/frontend/src/modules/shared/components/ui/button.tsx b/apps/frontend/src/modules/shared/components/ui/button.tsx
      new file mode 100644
      index 00000000..b5aac19e
      --- /dev/null
      +++ b/apps/frontend/src/modules/shared/components/ui/button.tsx
      @@ -0,0 +1,58 @@
      +import { Slot } from '@radix-ui/react-slot';
      +import { cva, type VariantProps } from 'class-variance-authority';
      +import * as React from 'react';
      +
      +import { cn } from '@web/lib/utils';
      +
      +const buttonVariants = cva(
      +  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-oklch(0.705 0.015 286.067) disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-oklch(0.552 0.016 285.938)',
      +  {
      +    variants: {
      +      variant: {
      +        default:
      +          'bg-oklch(0.21 0.006 285.885) text-oklch(0.985 0 0) shadow hover:bg-oklch(0.21 0.006 285.885)/90 dark:bg-oklch(0.92 0.004 286.32) dark:text-oklch(0.21 0.006 285.885) dark:hover:bg-oklch(0.92 0.004 286.32)/90',
      +        destructive:
      +          'bg-oklch(0.577 0.245 27.325) text-destructive-foreground shadow-sm hover:bg-oklch(0.577 0.245 27.325)/90 dark:bg-oklch(0.704 0.191 22.216) dark:hover:bg-oklch(0.704 0.191 22.216)/90',
      +        outline:
      +          'border border-oklch(0.92 0.004 286.32) bg-oklch(1 0 0) shadow-sm hover:bg-oklch(0.967 0.001 286.375) hover:text-oklch(0.21 0.006 285.885) dark:border-oklch(1 0 0 / 15%) dark:bg-oklch(0.141 0.005 285.823) dark:hover:bg-oklch(0.274 0.006 286.033) dark:hover:text-oklch(0.985 0 0)',
      +        secondary:
      +          'bg-oklch(0.967 0.001 286.375) text-oklch(0.21 0.006 285.885) shadow-sm hover:bg-oklch(0.967 0.001 286.375)/80 dark:bg-oklch(0.274 0.006 286.033) dark:text-oklch(0.985 0 0) dark:hover:bg-oklch(0.274 0.006 286.033)/80',
      +        ghost:
      +          'hover:bg-oklch(0.967 0.001 286.375) hover:text-oklch(0.21 0.006 285.885) dark:hover:bg-oklch(0.274 0.006 286.033) dark:hover:text-oklch(0.985 0 0)',
      +        link: 'text-oklch(0.21 0.006 285.885) underline-offset-4 hover:underline dark:text-oklch(0.92 0.004 286.32)',
      +      },
      +      size: {
      +        default: 'h-9 px-4 py-2',
      +        sm: 'h-8 rounded-md px-3 text-xs',
      +        lg: 'h-10 rounded-md px-8',
      +        icon: 'h-9 w-9',
      +      },
      +    },
      +    defaultVariants: {
      +      variant: 'default',
      +      size: 'default',
      +    },
      +  },
      +);
      +
      +export interface ButtonProps
      +  extends React.ButtonHTMLAttributes,
      +    VariantProps {
      +  asChild?: boolean;
      +}
      +
      +const Button = React.forwardRef(
      +  ({ className, variant, size, asChild = false, ...props }, ref) => {
      +    const Comp = asChild ? Slot : 'button';
      +    return (
      +      
      +    );
      +  },
      +);
      +Button.displayName = 'Button';
      +
      +export { Button, buttonVariants };
      diff --git a/apps/frontend/src/modules/shared/components/ui/input.tsx b/apps/frontend/src/modules/shared/components/ui/input.tsx
      new file mode 100644
      index 00000000..1f129a33
      --- /dev/null
      +++ b/apps/frontend/src/modules/shared/components/ui/input.tsx
      @@ -0,0 +1,22 @@
      +import * as React from 'react';
      +
      +import { cn } from '@web/lib/utils';
      +
      +const Input = React.forwardRef>(
      +  ({ className, type, ...props }, ref) => {
      +    return (
      +      
      +    );
      +  },
      +);
      +Input.displayName = 'Input';
      +
      +export { Input };
      diff --git a/apps/frontend/src/modules/shared/components/ui/label.tsx b/apps/frontend/src/modules/shared/components/ui/label.tsx
      new file mode 100644
      index 00000000..9f41c64b
      --- /dev/null
      +++ b/apps/frontend/src/modules/shared/components/ui/label.tsx
      @@ -0,0 +1,26 @@
      +'use client';
      +
      +import * as LabelPrimitive from '@radix-ui/react-label';
      +import { cva, type VariantProps } from 'class-variance-authority';
      +import * as React from 'react';
      +
      +import { cn } from '@web/lib/utils';
      +
      +const labelVariants = cva(
      +  'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
      +);
      +
      +const Label = React.forwardRef<
      +  React.ElementRef,
      +  React.ComponentPropsWithoutRef &
      +    VariantProps
      +>(({ className, ...props }, ref) => (
      +  
      +));
      +Label.displayName = LabelPrimitive.Root.displayName;
      +
      +export { Label };
      diff --git a/apps/frontend/src/modules/shared/components/ui/textarea.tsx b/apps/frontend/src/modules/shared/components/ui/textarea.tsx
      new file mode 100644
      index 00000000..c9f7e30c
      --- /dev/null
      +++ b/apps/frontend/src/modules/shared/components/ui/textarea.tsx
      @@ -0,0 +1,22 @@
      +import * as React from 'react';
      +
      +import { cn } from '@web/lib/utils';
      +
      +const Textarea = React.forwardRef<
      +  HTMLTextAreaElement,
      +  React.ComponentProps<'textarea'>
      +>(({ className, ...props }, ref) => {
      +  return (
      +