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..f6914e14 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,13 +1,14 @@ 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'; @@ -21,7 +22,7 @@ import { UserModule } from './user/user.module'; ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.test', '.env.development', '.env.production'], - validate, + validate: validateEnv, }), //DatabaseModule, MongooseModule.forRootAsync({ @@ -82,6 +83,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/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..46aa03a4 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,7 +183,7 @@ describe('SongController', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'desc', }), undefined, undefined, @@ -182,7 +191,7 @@ describe('SongController', () => { }); it('should handle recent sort with category', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 10, sort: SongSortType.RECENT, @@ -197,9 +206,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,7 +217,7 @@ describe('SongController', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'desc', }), undefined, 'pop', @@ -215,7 +225,7 @@ describe('SongController', () => { }); it('should handle category filter', async () => { - const query: SongListQueryDTO = { + const query: SongListQueryInput = { page: 1, limit: 10, category: 'rock', @@ -229,16 +239,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 +261,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 +272,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 +284,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 +305,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 +331,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 +357,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 +431,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 +444,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 +457,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 +468,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 +490,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 +513,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 +525,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 +538,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 +562,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 +584,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 +604,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 +626,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 +655,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 +678,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 +690,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 +701,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 +738,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 +757,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 +771,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 +787,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 +801,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 +818,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 +833,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 +855,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 +889,7 @@ describe('SongController', () => { ); await expect( - songController.getSongFile(id, src, user, res), + songController.getSongFile({ id }, { src }, user, res), ).rejects.toThrow('Error'); }); }); @@ -851,7 +905,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 +927,7 @@ describe('SongController', () => { const src = 'invalid-src'; await expect( - songController.getSongOpenUrl(id, user, src), + songController.getSongOpenUrl({ id }, user, { src }), ).rejects.toThrow(UnauthorizedException); }); @@ -887,7 +943,7 @@ describe('SongController', () => { ); await expect( - songController.getSongOpenUrl(id, user, src), + songController.getSongOpenUrl({ id }, user, { src }), ).rejects.toThrow('Error'); }); }); @@ -901,7 +957,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 +970,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..8a1c5ac7 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,13 +138,12 @@ 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 const result = await this.songService.querySongs( @@ -145,12 +152,13 @@ export class SongController { query.category, ); - return new PageDto({ + return { content: result.content, page: query.page, limit: query.limit, total: result.total, - }); + order: isDescending, + }; } @Get('/featured') @@ -164,7 +172,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 +216,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 +264,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 +291,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 +340,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 +358,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..3cbd7c43 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1,25 +1,22 @@ -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 { 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(), @@ -181,7 +178,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 +258,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 +390,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 +530,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'asc' as const, }; const songList: SongWithUser[] = []; @@ -551,7 +548,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 +564,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 +610,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 +838,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 +857,7 @@ describe('SongService', () => { expect(result).toEqual({ content: songList.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ), page: 1, limit: 10, @@ -908,7 +895,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 +985,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'stats.duration', - order: false, + order: 'asc' as const, }; const category = 'pop'; const songList: SongWithUser[] = []; @@ -1017,7 +1004,7 @@ describe('SongService', () => { const result = await service.querySongs(query, undefined, category); 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 +1038,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'createdAt', - order: true, + order: 'desc' as const, }; const songList: SongWithUser[] = []; @@ -1071,7 +1058,7 @@ describe('SongService', () => { 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 +1068,7 @@ describe('SongService', () => { page: 1, limit: 10, sort: 'playCount', - order: false, + order: 'asc' as const, }; const searchTerm = 'test song'; const category = 'rock'; @@ -1101,7 +1088,7 @@ describe('SongService', () => { const result = await service.querySongs(query, searchTerm, category); expect(result.content).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + songList.map((song) => songPreviewFromSongDocumentWithUser(song)), ); expect(result.total).toBe(0); @@ -1180,7 +1167,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 +1194,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..e35b1a35 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -10,21 +10,31 @@ 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 { 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 { @@ -82,7 +92,7 @@ export class SongService { // Save song document await songDocument.save(); - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); + return uploadSongResponseDtoFromSongWithUser(populatedSong); } public async deleteSong( @@ -112,7 +122,7 @@ export class SongService { await this.songWebhookService.deleteSongWebhook(populatedSong); - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); + return uploadSongResponseDtoFromSongWithUser(populatedSong); } public async patchSong( @@ -171,20 +181,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 +203,33 @@ 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, ): 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,8 +238,8 @@ 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 = { @@ -246,18 +260,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 +284,7 @@ export class SongService { ]); return { - content: songs.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ), + content: songs.map((song) => songPreviewFromSongDocumentWithUser(song)), page, limit, total, @@ -339,7 +350,9 @@ export class SongService { 'username profileImage -_id', ); - 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 +410,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 +435,7 @@ export class SongService { return { content: songData.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), + songPreviewFromSongDocumentWithUser(song), ), page: page, limit: limit, @@ -445,7 +459,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 +525,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..108e116c 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,104 @@ export const generateSongId = () => { return nanoid(); }; +export function uploadSongResponseDtoFromSongWithUser( + song: SongWithUser, +): UploadSongResponseDto { + return { + publicId: song.publicId, + title: song.title, + uploader: { + 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 { + return { + publicId: song.publicId, + createdAt: song.createdAt, + uploader: { + 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..d3a813c2 --- /dev/null +++ b/apps/backend/src/zod-dto/index.ts @@ -0,0 +1,12 @@ +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 { 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/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-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..23325015 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,7 +18,6 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@nbw/config": "workspace:*", - "@nbw/database": "workspace:*", "@nbw/song": "workspace:*", "@nbw/thumbnail": "workspace:*", "@next/mdx": "^16.0.8", 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/global.d.ts b/apps/frontend/src/global.d.ts index a5f1e30d..edf23930 100644 --- a/apps/frontend/src/global.d.ts +++ b/apps/frontend/src/global.d.ts @@ -1,7 +1,7 @@ // https://stackoverflow.com/a/56984941/9045426 // https://stackoverflow.com/a/43523944/9045426 -import type { SoundListType } from '@nbw/database'; +import type { SoundListType } from '@nbw/sounds'; interface Window { latestVersionSoundList: SoundListType; 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/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/song-edit/components/client/EditSongPage.tsx b/apps/frontend/src/modules/song-edit/components/client/EditSongPage.tsx index e6da180d..76944d8e 100644 --- a/apps/frontend/src/modules/song-edit/components/client/EditSongPage.tsx +++ b/apps/frontend/src/modules/song-edit/components/client/EditSongPage.tsx @@ -1,4 +1,4 @@ -import type { UploadSongDtoType } from '@nbw/database'; +import type { UploadSongDto } from '@nbw/validation'; import axiosInstance from '@web/lib/axios'; import { getTokenServer, @@ -13,7 +13,7 @@ import { import { SongEditForm } from './SongEditForm'; -async function fetchSong({ id }: { id: string }): Promise { +async function fetchSong({ id }: { id: string }): Promise { // get token from cookies const token = await getTokenServer(); // if token is not null, redirect to home page @@ -31,7 +31,7 @@ async function fetchSong({ id }: { id: string }): Promise { const data = await response.data; - return data as UploadSongDtoType; + return data as UploadSongDto; } catch (error: unknown) { throw new Error('Failed to fetch song data'); } diff --git a/apps/frontend/src/modules/song-edit/components/client/SongEditForm.tsx b/apps/frontend/src/modules/song-edit/components/client/SongEditForm.tsx index 525d556d..32c5cc4f 100644 --- a/apps/frontend/src/modules/song-edit/components/client/SongEditForm.tsx +++ b/apps/frontend/src/modules/song-edit/components/client/SongEditForm.tsx @@ -2,14 +2,14 @@ import { useEffect } from 'react'; -import type { UploadSongDtoType } from '@nbw/database'; +import type { UploadSongDto } from '@nbw/validation'; import { useSongProvider } from '@web/modules/song/components/client/context/Song.context'; import { SongForm } from '@web/modules/song/components/client/SongForm'; import { useEditSongProviderType } from './context/EditSong.context'; type SongEditFormProps = { - songData: UploadSongDtoType; + songData: UploadSongDto; songId: string; username: string; }; diff --git a/apps/frontend/src/modules/song-edit/components/client/context/EditSong.context.tsx b/apps/frontend/src/modules/song-edit/components/client/context/EditSong.context.tsx index 914aeca1..a4abcaab 100644 --- a/apps/frontend/src/modules/song-edit/components/client/context/EditSong.context.tsx +++ b/apps/frontend/src/modules/song-edit/components/client/context/EditSong.context.tsx @@ -1,7 +1,6 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AxiosError } from 'axios'; import { useRouter } from 'next/navigation'; import { createContext, useCallback, useEffect, useState } from 'react'; import { @@ -13,15 +12,15 @@ import { import toaster from 'react-hot-toast'; import { undefined as zodUndefined } from 'zod'; -import type { UploadSongDto } from '@nbw/database'; import { parseSongFromBuffer, type SongFileType } from '@nbw/song'; -import axiosInstance from '@web/lib/axios'; -import { getTokenLocal } from '@web/lib/axios/token.utils'; +import type { UploadSongDto } from '@nbw/validation'; import { EditSongFormInput, EditSongFormOutput, editSongFormSchema, -} from '@web/modules/song/components/client/SongForm.zod'; +} from '@nbw/validation'; +import axiosInstance from '@web/lib/axios'; +import { getTokenLocal } from '@web/lib/axios/token.utils'; export type useEditSongProviderType = { formMethods: UseFormReturn; diff --git a/apps/frontend/src/modules/song-search/SearchSongPage.tsx b/apps/frontend/src/modules/song-search/SearchSongPage.tsx index d74bf19c..525976a5 100644 --- a/apps/frontend/src/modules/song-search/SearchSongPage.tsx +++ b/apps/frontend/src/modules/song-search/SearchSongPage.tsx @@ -16,8 +16,18 @@ import { useEffect, useMemo, useState } from 'react'; import Skeleton from 'react-loading-skeleton'; import { create } from 'zustand'; -import { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config'; -import { SongPreviewDtoType } from '@nbw/database'; +import { + INSTRUMENTS, + SEARCH_FEATURES, + SEARCH_SONGS, + UPLOAD_CONSTANTS, +} from '@nbw/config'; +import type { + SongPageDto, + SongPreviewDto, + SongSearchParams, +} from '@nbw/validation'; +import { SongOrderType, SongSortType } from '@nbw/validation'; import axiosInstance from '@web/lib/axios'; import LoadMoreButton from '@web/modules/browse/components/client/LoadMoreButton'; import SongCard from '@web/modules/browse/components/SongCard'; @@ -25,54 +35,19 @@ import SongCardGroup from '@web/modules/browse/components/SongCardGroup'; import { DualRangeSlider } from '@web/modules/shared/components/ui/dualRangeSlider'; import MultipleSelector from '@web/modules/shared/components/ui/multipleSelectorProps'; -interface SearchParams { - q?: string; - sort?: string; - order?: string; - category?: string; - uploader?: string; - limit?: number; - noteCountMin?: number; - noteCountMax?: number; - durationMin?: number; - durationMax?: number; - features?: string; - instruments?: string; -} -interface PageDto { - content: T[]; - page: number; - limit: number; - total: number; -} -// TODO: importing these enums from '@nbw/database' is causing issues. -// They shouldn't be redefined here. -enum SongSortType { - RECENT = 'recent', - RANDOM = 'random', - PLAY_COUNT = 'playCount', - TITLE = 'title', - DURATION = 'duration', - NOTE_COUNT = 'noteCount', -} -enum SongOrderType { - ASC = 'asc', - DESC = 'desc', -} -// TODO: refactor with PAGE_SIZE constant -const PLACEHOLDER_COUNT = 12; +const PLACEHOLDER_COUNT = SEARCH_SONGS.placeholderCount; const makePlaceholders = () => Array.from({ length: PLACEHOLDER_COUNT }, () => null); interface SongSearchState { - songs: Array; + songs: Array; loading: boolean; hasMore: boolean; currentPage: number; totalResults: number; } interface SongSearchActions { - searchSongs: (params: SearchParams, pageNum: number) => Promise; - loadMore: (params: SearchParams) => Promise; + searchSongs: (params: SongSearchParams, pageNum: number) => Promise; + loadMore: (params: SongSearchParams) => Promise; } const initialState: SongSearchState = { songs: [], @@ -104,13 +79,12 @@ export const useSongSearchStore = create( } try { - const response = await axiosInstance.get>( - '/song', - { params: { ...params, page: pageNum } }, - ); + const response = await axiosInstance.get('/song', { + params: { ...params, page: pageNum }, + }); const { content, total } = response.data; - const limit = params.limit || 12; + const limit = params.limit || SEARCH_SONGS.pageSize; set((state) => ({ // Remove placeholders and add the new results @@ -387,7 +361,7 @@ const NoResults = () => (
); interface SearchResultsProps { - songs: Array; + songs: Array; loading: boolean; hasMore: boolean; onLoadMore: () => void; @@ -423,7 +397,7 @@ export const SearchSongPage = () => { category: parseAsString.withDefault(''), uploader: parseAsString.withDefault(''), page: parseAsInteger.withDefault(1), - limit: parseAsInteger.withDefault(12), + limit: parseAsInteger.withDefault(SEARCH_SONGS.pageSize), noteCountMin: parseAsInteger, noteCountMax: parseAsInteger, durationMin: parseAsInteger, @@ -454,12 +428,22 @@ export const SearchSongPage = () => { useSongSearchStore(); const [showFilters, setShowFilters] = useState(false); + const normalizedSort = Object.values(SongSortType).includes( + sort as SongSortType, + ) + ? (sort as SongSortType) + : undefined; + const normalizedOrder = Object.values(SongOrderType).includes( + order as SongOrderType, + ) + ? (order as SongOrderType) + : undefined; useEffect(() => { - const params: SearchParams = { + const params: SongSearchParams = { q: query, - sort, - order, + sort: normalizedSort, + order: normalizedOrder, category, uploader, limit, @@ -474,8 +458,8 @@ export const SearchSongPage = () => { searchSongs(params, initialPage); }, [ query, - sort, - order, + normalizedSort, + normalizedOrder, category, uploader, initialPage, diff --git a/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx b/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx index 12ccb606..97a6b3c7 100644 --- a/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx +++ b/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx @@ -19,7 +19,7 @@ import { UploadSongFormInput, UploadSongFormOutput, uploadSongFormSchema, -} from '@web/modules/song/components/client/SongForm.zod'; +} from '@nbw/validation'; import UploadCompleteModal from '../UploadCompleteModal'; diff --git a/apps/frontend/src/modules/song/components/SongDetails.tsx b/apps/frontend/src/modules/song/components/SongDetails.tsx index 5059f10b..17f81091 100644 --- a/apps/frontend/src/modules/song/components/SongDetails.tsx +++ b/apps/frontend/src/modules/song/components/SongDetails.tsx @@ -2,14 +2,14 @@ import { faCheck, faClose } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { UPLOAD_CONSTANTS } from '@nbw/config'; -import type { SongViewDtoType } from '@nbw/database'; +import type { SongViewDto } from '@nbw/validation'; import { formatDuration, formatTimeSpent, } from '@web/modules/shared/util/format'; type SongDetailsProps = { - song: SongViewDtoType; + song: SongViewDto; }; const SongDetailsRow = ({ children }: { children: React.ReactNode }) => { diff --git a/apps/frontend/src/modules/song/components/SongPage.tsx b/apps/frontend/src/modules/song/components/SongPage.tsx index eca3878c..f58fb293 100644 --- a/apps/frontend/src/modules/song/components/SongPage.tsx +++ b/apps/frontend/src/modules/song/components/SongPage.tsx @@ -1,11 +1,7 @@ import { cookies } from 'next/headers'; import Image from 'next/image'; -import type { - PageDto, - SongPreviewDtoType, - SongViewDtoType, -} from '@nbw/database'; +import type { PageDto, SongPreviewDto, SongViewDto } from '@nbw/validation'; import axios from '@web/lib/axios'; import SongCard from '@web/modules/browse/components/SongCard'; import SongCardGroup from '@web/modules/browse/components/SongCardGroup'; @@ -24,7 +20,7 @@ import { } from './SongPageButtons'; export async function SongPage({ id }: { id: string }) { - let song: SongViewDtoType; + let song: SongViewDto; // get 'token' cookie from headers const cookieStore = await cookies(); @@ -37,7 +33,7 @@ export async function SongPage({ id }: { id: string }) { } try { - const response = await axios.get(`/song/${id}`, { + const response = await axios.get(`/song/${id}`, { headers, }); @@ -46,10 +42,10 @@ export async function SongPage({ id }: { id: string }) { return ; } - let suggestions: SongPreviewDtoType[] = []; + let suggestions: SongPreviewDto[] = []; try { - const response = await axios.get>(`/song`, { + const response = await axios.get>(`/song`, { params: { sort: 'random', limit: 4, diff --git a/apps/frontend/src/modules/song/components/SongPageButtons.tsx b/apps/frontend/src/modules/song/components/SongPageButtons.tsx index a178d6c6..52bf33df 100644 --- a/apps/frontend/src/modules/song/components/SongPageButtons.tsx +++ b/apps/frontend/src/modules/song/components/SongPageButtons.tsx @@ -16,7 +16,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import { toast } from 'react-hot-toast'; -import { SongViewDtoType } from '@nbw/database'; +import type { SongViewDto } from '@nbw/validation'; import { getTokenLocal } from '@web/lib/axios/token.utils'; import { @@ -38,7 +38,7 @@ const VisibilityBadge = () => { ); }; -const UploaderBadge = ({ user }: { user: SongViewDtoType['uploader'] }) => { +const UploaderBadge = ({ user }: { user: SongViewDto['uploader'] }) => { return (
{ ); }; -const DownloadSongButton = ({ song }: { song: SongViewDtoType }) => { +const DownloadSongButton = ({ song }: { song: SongViewDto }) => { const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false); return ( diff --git a/apps/frontend/src/modules/song/components/client/DownloadSongModal.tsx b/apps/frontend/src/modules/song/components/client/DownloadSongModal.tsx index c6533a9b..f8e46163 100644 --- a/apps/frontend/src/modules/song/components/client/DownloadSongModal.tsx +++ b/apps/frontend/src/modules/song/components/client/DownloadSongModal.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; -import type { SongViewDtoType } from '@nbw/database'; +import type { SongViewDto } from '@nbw/validation'; import { DownloadPopupAdSlot } from '@web/modules/shared/components/client/ads/AdSlots'; import GenericModal from '@web/modules/shared/components/client/GenericModal'; @@ -13,7 +13,7 @@ export default function DownloadSongModal({ }: { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; - song: SongViewDtoType; + song: SongViewDto; }) { const [isCopied, setIsCopied] = useState(false); diff --git a/apps/frontend/src/modules/song/components/client/SongCanvas.tsx b/apps/frontend/src/modules/song/components/client/SongCanvas.tsx index cac049d6..c7d02538 100644 --- a/apps/frontend/src/modules/song/components/client/SongCanvas.tsx +++ b/apps/frontend/src/modules/song/components/client/SongCanvas.tsx @@ -2,10 +2,10 @@ import { useEffect, useRef } from 'react'; -import type { SongViewDtoType } from '@nbw/database'; +import type { SongViewDto } from '@nbw/validation'; import axios from '@web/lib/axios'; -export const SongCanvas = ({ song }: { song: SongViewDtoType }) => { +export const SongCanvas = ({ song }: { song: SongViewDto }) => { const canvasContainerRef = useRef(null); const wasmModuleRef = useRef(null); let scriptTag: HTMLScriptElement | null = null; diff --git a/apps/frontend/src/modules/song/components/client/SongForm.tsx b/apps/frontend/src/modules/song/components/client/SongForm.tsx index c5539c91..52e3e566 100644 --- a/apps/frontend/src/modules/song/components/client/SongForm.tsx +++ b/apps/frontend/src/modules/song/components/client/SongForm.tsx @@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation'; import React from 'react'; import { UPLOAD_CONSTANTS } from '@nbw/config'; -import type { LicenseType } from '@nbw/database'; +import type { LicenseType } from '@nbw/validation'; import { ErrorBalloon } from '@web/modules/shared/components/client/ErrorBalloon'; import { ErrorBox } from '@web/modules/shared/components/client/ErrorBox'; import { diff --git a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx index 868b7b03..beb78796 100644 --- a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx +++ b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx @@ -11,7 +11,7 @@ import { } from '@web/modules/shared/components/tooltip'; import { useSongProvider } from './context/Song.context'; -import { EditSongFormInput, UploadSongFormInput } from './SongForm.zod'; +import { EditSongFormInput, UploadSongFormInput } from '@nbw/validation'; import { ThumbnailRendererCanvas } from './ThumbnailRenderer'; const formatZoomLevel = (zoomLevel: number) => { diff --git a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx index c7d77afa..6d13be0c 100644 --- a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx +++ b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx @@ -7,7 +7,7 @@ import { THUMBNAIL_CONSTANTS } from '@nbw/config'; import { NoteQuadTree } from '@nbw/song'; import { drawNotesOffscreen, swap } from '@nbw/thumbnail/browser'; -import { UploadSongFormInput } from './SongForm.zod'; +import { UploadSongFormInput } from '@nbw/validation'; type ThumbnailRendererCanvasProps = { notes: NoteQuadTree; diff --git a/apps/frontend/src/modules/user/features/user.util.ts b/apps/frontend/src/modules/user/features/user.util.ts index 70e8dd00..c54d69b5 100644 --- a/apps/frontend/src/modules/user/features/user.util.ts +++ b/apps/frontend/src/modules/user/features/user.util.ts @@ -6,8 +6,11 @@ export const getUserProfileData = async ( ): Promise => { try { const res = await axiosInstance.get(`/user/?id=${id}`); - if (res.status === 200) return res.data as UserProfileData; - else throw new Error('Failed to get user data'); + if (res.status === 200) { + const user = (res.data as { users?: UserProfileData[] }).users?.[0]; + if (user) return user; + } + throw new Error('Failed to get user data'); } catch { throw new Error('Failed to get user data'); } diff --git a/bun.lock b/bun.lock index d9b02ac3..2ffb5051 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@nbw/song": "workspace:*", "@nbw/sounds": "workspace:*", "@nbw/thumbnail": "workspace:*", + "@nbw/validation": "workspace:*", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", @@ -56,12 +57,12 @@ "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", "multer": "2.1.1", "nanoid": "^5.1.6", + "nestjs-zod": "^5.0.1", "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-google-oauth20": "^2.0.0", @@ -116,7 +117,6 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@nbw/config": "workspace:*", - "@nbw/database": "workspace:*", "@nbw/song": "workspace:*", "@nbw/thumbnail": "workspace:*", "@next/mdx": "^16.0.8", @@ -187,11 +187,10 @@ "name": "@nbw/database", "dependencies": { "@nbw/config": "workspace:*", + "@nbw/validation": "workspace:*", "@nestjs/common": "^11.1.9", "@nestjs/mongoose": "^10.1.0", "@nestjs/swagger": "^11.2.3", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", "mongoose": "^9.0.1", }, "devDependencies": { @@ -247,6 +246,23 @@ "typescript": "^5", }, }, + "packages/validation": { + "name": "@nbw/validation", + "dependencies": { + "@nbw/config": "workspace:*", + "ms": "^2.1.3", + "zod": "^4.1.13", + "zod-validation-error": "^5.0.0", + }, + "devDependencies": { + "@types/bun": "^1.3.4", + "@types/ms": "^2.1.0", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, }, "trustedDependencies": [ "@nestjs/core", @@ -692,6 +708,8 @@ "@nbw/thumbnail": ["@nbw/thumbnail@workspace:packages/thumbnail"], + "@nbw/validation": ["@nbw/validation@workspace:packages/validation"], + "@nestjs-modules/mailer": ["@nestjs-modules/mailer@2.0.2", "", { "dependencies": { "@css-inline/css-inline": "0.14.1", "glob": "10.3.12" }, "optionalDependencies": { "@types/ejs": "^3.1.5", "@types/mjml": "^4.7.4", "@types/pug": "^2.0.10", "ejs": "^3.1.10", "handlebars": "^4.7.8", "liquidjs": "^10.11.1", "mjml": "^4.15.3", "preview-email": "^3.0.19", "pug": "^3.0.2" }, "peerDependencies": { "@nestjs/common": ">=7.0.9", "@nestjs/core": ">=7.0.9", "nodemailer": ">=6.4.6" } }, "sha512-+z4mADQasg0H1ZaGu4zZTuKv2pu+XdErqx99PLFPzCDNTN/q9U59WPgkxVaHnsvKHNopLj5Xap7G4ZpptduoYw=="], "@nestjs/cli": ["@nestjs/cli@11.0.14", "", { "dependencies": { "@angular-devkit/core": "19.2.19", "@angular-devkit/schematics": "19.2.19", "@angular-devkit/schematics-cli": "19.2.19", "@inquirer/prompts": "7.10.1", "@nestjs/schematics": "^11.0.1", "ansis": "4.2.0", "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.1.0", "glob": "13.0.0", "node-emoji": "1.11.0", "ora": "5.4.1", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", "typescript": "5.9.3", "webpack": "5.103.0", "webpack-node-externals": "3.0.0" }, "peerDependencies": { "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", "@swc/core": "^1.3.62" }, "optionalPeers": ["@swc/cli", "@swc/core"], "bin": { "nest": "bin/nest.js" } }, "sha512-YwP03zb5VETTwelXU+AIzMVbEZKk/uxJL+z9pw0mdG9ogAtqZ6/mpmIM4nEq/NU8D0a7CBRLcMYUmWW/55pfqw=="], @@ -2472,6 +2490,8 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "nestjs-zod": ["nestjs-zod@5.3.0", "", { "dependencies": { "deepmerge": "^4.3.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/swagger": "^7.4.2 || ^8.0.0 || ^11.0.0", "rxjs": "^7.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@nestjs/swagger"] }, "sha512-QY6imXm9heMOpWigjFHgMWPvc1ZQHeNQ7pdogo9Q5xj5F8HpqZ972vKlVdkaTyzYlOXJP/yVy3wlF1EjubDQPg=="], + "next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="], "next-recaptcha-v3": ["next-recaptcha-v3@1.5.3", "", { "peerDependencies": { "next": "^13 || ^14 || ^15 || ^16", "react": "^18 || ^19" } }, "sha512-Osnt1gj0+Mor8rc42NCzpteQrrSbcxskGLOeWLU/T0xdXtJE90y/gFyp87/yN1goIMI+gXs5f0PMymMa29nuLA=="], @@ -3312,24 +3332,26 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@nbw/backend/@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@nbw/backend/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@nbw/backend/@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], - "@nbw/config/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@nbw/config/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], - "@nbw/database/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@nbw/database/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@nbw/frontend/@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], - "@nbw/song/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@nbw/song/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], - "@nbw/sounds/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@nbw/sounds/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], - "@nbw/thumbnail/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@nbw/thumbnail/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@nbw/thumbnail/jest": ["jest@29.7.0", "", { "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", "import-local": "^3.0.2", "jest-cli": "^29.7.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw=="], + "@nbw/validation/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@nestjs-modules/mailer/glob": ["glob@10.3.12", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", "minimatch": "^9.0.1", "minipass": "^7.0.4", "path-scurry": "^1.10.2" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg=="], "@nestjs/cli/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -4074,21 +4096,21 @@ "@jest/transform/jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "@nbw/backend/@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "@nbw/backend/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "@nbw/backend/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@nbw/config/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@nbw/config/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "@nbw/database/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@nbw/database/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "@nbw/frontend/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@nbw/song/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@nbw/song/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "@nbw/sounds/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@nbw/sounds/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "@nbw/thumbnail/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "@nbw/thumbnail/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "@nbw/thumbnail/jest/@jest/core": ["@jest/core@29.7.0", "", { "dependencies": { "@jest/console": "^29.7.0", "@jest/reporters": "^29.7.0", "@jest/test-result": "^29.7.0", "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", "ci-info": "^3.2.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-changed-files": "^29.7.0", "jest-config": "^29.7.0", "jest-haste-map": "^29.7.0", "jest-message-util": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-resolve": "^29.7.0", "jest-resolve-dependencies": "^29.7.0", "jest-runner": "^29.7.0", "jest-runtime": "^29.7.0", "jest-snapshot": "^29.7.0", "jest-util": "^29.7.0", "jest-validate": "^29.7.0", "jest-watcher": "^29.7.0", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg=="], @@ -4096,6 +4118,8 @@ "@nbw/thumbnail/jest/jest-cli": ["jest-cli@29.7.0", "", { "dependencies": { "@jest/core": "^29.7.0", "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", "chalk": "^4.0.0", "create-jest": "^29.7.0", "exit": "^0.1.2", "import-local": "^3.0.2", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "jest-validate": "^29.7.0", "yargs": "^17.3.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg=="], + "@nbw/validation/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@nestjs-modules/mailer/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@nestjs-modules/mailer/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -4436,6 +4460,8 @@ "@nbw/thumbnail/jest/jest-cli/jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + "@nbw/validation/@types/bun/bun-types/@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + "@nestjs-modules/mailer/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@nestjs-modules/mailer/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4696,6 +4722,8 @@ "@nbw/thumbnail/jest/jest-cli/jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "@nbw/validation/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@nestjs/mongoose/mongoose/mongodb/mongodb-connection-string-url/@types/whatwg-url": ["@types/whatwg-url@8.2.2", "", { "dependencies": { "@types/node": "*", "@types/webidl-conversions": "*" } }, "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA=="], "@nestjs/mongoose/mongoose/mongodb/mongodb-connection-string-url/whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="], diff --git a/packages/configs/src/song.ts b/packages/configs/src/song.ts index 613125f7..a9fba9d7 100644 --- a/packages/configs/src/song.ts +++ b/packages/configs/src/song.ts @@ -130,6 +130,11 @@ export const BROWSER_SONGS = { paddedFeaturedPageSize: 5, } as const; +export const SEARCH_SONGS = { + pageSize: 12, + placeholderCount: 12, +} as const; + export const SEARCH_FEATURES: Record = { 'CC License': 'CCLicense', Downloadable: 'Downloadable', diff --git a/packages/database/package.json b/packages/database/package.json index 61e5d06f..297e963d 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -9,10 +9,6 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" - }, - "./types": { - "import": "./dist/song/dto/types.js", - "types": "./dist/song/dto/types.d.ts" } }, "scripts": { @@ -32,10 +28,9 @@ "@nestjs/common": "^11.1.9", "@nestjs/mongoose": "^10.1.0", "@nestjs/swagger": "^11.2.3", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", "mongoose": "^9.0.1", - "@nbw/config": "workspace:*" + "@nbw/config": "workspace:*", + "@nbw/validation": "workspace:*" }, "peerDependencies": { "typescript": "^5" diff --git a/packages/database/src/common/dto/Page.dto.ts b/packages/database/src/common/dto/Page.dto.ts deleted file mode 100644 index 32a6a469..00000000 --- a/packages/database/src/common/dto/Page.dto.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsArray, - IsBoolean, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - ValidateNested, -} from 'class-validator'; - -export class PageDto { - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @ApiProperty({ example: 150, description: 'Total number of items available' }) - total: number; - - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @ApiProperty({ example: 1, description: 'Current page number' }) - page: number; - - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @ApiProperty({ example: 20, description: 'Number of items per page' }) - limit: number; - - @IsOptional() - @IsString() - @ApiProperty({ example: 'createdAt', description: 'Field used for sorting' }) - sort?: string; - - @IsNotEmpty() - @IsBoolean() - @ApiProperty({ - example: false, - description: 'Sort order: true for ascending, false for descending', - }) - order: boolean; - - @IsNotEmpty() - @IsArray() - @ValidateNested({ each: true }) - @ApiProperty({ - description: 'Array of items for the current page', - isArray: true, - }) - content: T[]; - - constructor(partial: Partial>) { - Object.assign(this, partial); - } -} diff --git a/packages/database/src/common/dto/PageQuery.dto.ts b/packages/database/src/common/dto/PageQuery.dto.ts deleted file mode 100644 index a0f0025c..00000000 --- a/packages/database/src/common/dto/PageQuery.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { - IsBoolean, - IsEnum, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - Max, - Min, -} from 'class-validator'; - -import { TIMESPANS } from '@nbw/config'; - -import type { TimespanType } from '../../song/dto/types'; - -export class PageQueryDTO { - @Min(1) - @ApiProperty({ - example: 1, - description: 'page', - }) - page?: number = 1; - - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @Min(1) - @Max(100) - @ApiProperty({ - example: 20, - description: 'limit', - }) - limit?: number; - - @IsString() - @IsOptional() - @ApiProperty({ - example: 'field', - description: 'Sorts the results by the specified field.', - required: false, - }) - sort?: string = 'createdAt'; - - @IsBoolean() - @Transform(({ value }) => value === 'true') - @ApiProperty({ - example: false, - description: - 'Sorts the results in ascending order if true; in descending order if false.', - required: false, - }) - order?: boolean = false; - - @IsEnum(TIMESPANS) - @IsOptional() - @ApiProperty({ - example: 'hour', - description: 'Filters the results by the specified timespan.', - required: false, - }) - timespan?: TimespanType; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} diff --git a/packages/database/src/common/dto/types.ts b/packages/database/src/common/dto/types.ts deleted file mode 100644 index 2b92e50b..00000000 --- a/packages/database/src/common/dto/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { PageQueryDTO } from './PageQuery.dto'; - -export type PageQueryDTOType = InstanceType; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index a9e1cc0a..166095be 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,26 +1,2 @@ -export * from './common/dto/Page.dto'; -export * from './common/dto/PageQuery.dto'; -export * from './common/dto/types'; - -export * from './song/dto/CustomInstrumentData.dto'; -export * from './song/dto/FeaturedSongsDto.dto'; -export * from './song/dto/SongListQuery.dto'; -export * from './song/dto/SongPage.dto'; -export * from './song/dto/SongPreview.dto'; -export * from './song/dto/SongStats'; -export * from './song/dto/SongView.dto'; -export * from './song/dto/ThumbnailData.dto'; -export * from './song/dto/UploadSongDto.dto'; -export * from './song/dto/UploadSongResponseDto.dto'; -export * from './song/dto/types'; -export * from './song/entity/song.entity'; - -export * from './user/dto/CreateUser.dto'; -export * from './user/dto/GetUser.dto'; -export * from './user/dto/Login.dto copy'; -export * from './user/dto/LoginWithEmail.dto'; -export * from './user/dto/NewEmailUser.dto'; -export * from './user/dto/SingleUsePass.dto'; -export * from './user/dto/UpdateUsername.dto'; -export * from './user/dto/user.dto'; -export * from './user/entity/user.entity'; +export * from './song/song.entity'; +export * from './user/user.entity'; diff --git a/packages/database/src/index.web.ts b/packages/database/src/index.web.ts deleted file mode 100644 index 3a9cde9e..00000000 --- a/packages/database/src/index.web.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Web-specific exports (excludes Mongoose entities) -export * from './common/dto/Page.dto'; -export * from './common/dto/PageQuery.dto'; -export * from './common/dto/types'; - -export * from './song/dto/CustomInstrumentData.dto'; -export * from './song/dto/FeaturedSongsDto.dto'; -export * from './song/dto/SongListQuery.dto'; -export * from './song/dto/SongPage.dto'; -export * from './song/dto/SongPreview.dto'; -export * from './song/dto/SongStats'; -export * from './song/dto/SongView.dto'; -export * from './song/dto/ThumbnailData.dto'; -export * from './song/dto/UploadSongDto.dto'; -export * from './song/dto/UploadSongResponseDto.dto'; -export * from './song/dto/types'; -// Note: song.entity is excluded for web builds - -export * from './user/dto/CreateUser.dto'; -export * from './user/dto/GetUser.dto'; -export * from './user/dto/Login.dto copy'; -export * from './user/dto/LoginWithEmail.dto'; -export * from './user/dto/NewEmailUser.dto'; -export * from './user/dto/SingleUsePass.dto'; -export * from './user/dto/UpdateUsername.dto'; -export * from './user/dto/user.dto'; -// Note: user.entity is excluded for web builds diff --git a/packages/database/src/song/dto/CustomInstrumentData.dto.ts b/packages/database/src/song/dto/CustomInstrumentData.dto.ts deleted file mode 100644 index 8cb3e835..00000000 --- a/packages/database/src/song/dto/CustomInstrumentData.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; - -export class CustomInstrumentData { - @IsNotEmpty() - sound: string[]; -} diff --git a/packages/database/src/song/dto/FeaturedSongsDto.dto.ts b/packages/database/src/song/dto/FeaturedSongsDto.dto.ts deleted file mode 100644 index 65d6eff7..00000000 --- a/packages/database/src/song/dto/FeaturedSongsDto.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SongPreviewDto } from './SongPreview.dto'; - -export class FeaturedSongsDto { - hour: SongPreviewDto[]; - day: SongPreviewDto[]; - week: SongPreviewDto[]; - month: SongPreviewDto[]; - year: SongPreviewDto[]; - all: SongPreviewDto[]; - - public static create(): FeaturedSongsDto { - return { - hour: [], - day: [], - week: [], - month: [], - year: [], - all: [], - }; - } -} diff --git a/packages/database/src/song/dto/SongListQuery.dto.ts b/packages/database/src/song/dto/SongListQuery.dto.ts deleted file mode 100644 index ae7f72bf..00000000 --- a/packages/database/src/song/dto/SongListQuery.dto.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsEnum, - IsNumber, - IsOptional, - IsString, - Max, - Min, -} from 'class-validator'; - -export enum SongSortType { - RECENT = 'recent', - RANDOM = 'random', - PLAY_COUNT = 'playCount', - TITLE = 'title', - DURATION = 'duration', - NOTE_COUNT = 'noteCount', -} - -export enum SongOrderType { - ASC = 'asc', - DESC = 'desc', -} - -export class SongListQueryDTO { - @IsString() - @IsOptional() - @ApiProperty({ - example: 'my search query', - description: 'Search string to filter songs by title or description', - required: false, - }) - q?: string; - - @IsEnum(SongSortType) - @IsOptional() - @ApiProperty({ - enum: SongSortType, - example: SongSortType.RECENT, - description: 'Sort songs by the specified criteria', - required: false, - }) - sort?: SongSortType = SongSortType.RECENT; - - @IsEnum(SongOrderType) - @IsOptional() - @ApiProperty({ - enum: SongOrderType, - example: SongOrderType.DESC, - description: 'Sort order (only applies if sort is not random)', - required: false, - }) - order?: SongOrderType = SongOrderType.DESC; - - @IsString() - @IsOptional() - @ApiProperty({ - example: 'pop', - description: - 'Filter by category. If left empty, returns songs in any category', - required: false, - }) - category?: string; - - @IsString() - @IsOptional() - @ApiProperty({ - example: 'username123', - description: - 'Filter by uploader username. If provided, will only return songs uploaded by that user', - required: false, - }) - uploader?: string; - - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @Min(1) - @ApiProperty({ - example: 1, - description: 'Page number', - required: false, - }) - page?: number = 1; - - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @Min(1) - @Max(100) - @ApiProperty({ - example: 10, - description: 'Number of items to return per page', - required: false, - }) - limit?: number = 10; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} diff --git a/packages/database/src/song/dto/SongPage.dto.ts b/packages/database/src/song/dto/SongPage.dto.ts deleted file mode 100644 index 4e1e0a70..00000000 --- a/packages/database/src/song/dto/SongPage.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IsArray, IsNotEmpty, IsNumber, ValidateNested } from 'class-validator'; - -import { SongPreviewDto } from './SongPreview.dto'; - -export class SongPageDto { - @IsNotEmpty() - @IsArray() - @ValidateNested() - content: Array; - - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - page: number; - - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - limit: number; - - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - total: number; -} diff --git a/packages/database/src/song/dto/SongPreview.dto.ts b/packages/database/src/song/dto/SongPreview.dto.ts deleted file mode 100644 index 38ce760a..00000000 --- a/packages/database/src/song/dto/SongPreview.dto.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { IsNotEmpty, IsString, IsUrl, MaxLength } from 'class-validator'; - -import type { SongWithUser } from '../../song/entity/song.entity'; - -import type { VisibilityType } from './types'; - -type SongPreviewUploader = { - username: string; - profileImage: string; -}; - -export class SongPreviewDto { - @IsString() - @IsNotEmpty() - publicId: string; - - @IsNotEmpty() - uploader: SongPreviewUploader; - - @IsNotEmpty() - @IsString() - @MaxLength(128) - title: string; - - @IsNotEmpty() - @IsString() - description: string; - - @IsNotEmpty() - @IsString() - @MaxLength(64) - originalAuthor: string; - - @IsNotEmpty() - duration: number; - - @IsNotEmpty() - noteCount: number; - - @IsNotEmpty() - @IsUrl() - thumbnailUrl: string; - - @IsNotEmpty() - createdAt: Date; - - @IsNotEmpty() - updatedAt: Date; - - @IsNotEmpty() - playCount: number; - - @IsNotEmpty() - @IsString() - visibility: VisibilityType; - - constructor(partial: Partial) { - Object.assign(this, partial); - } - - public static fromSongDocumentWithUser(song: SongWithUser): SongPreviewDto { - return new SongPreviewDto({ - publicId: song.publicId, - uploader: song.uploader, - 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, - }); - } -} diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts deleted file mode 100644 index 49cb712b..00000000 --- a/packages/database/src/song/dto/SongStats.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - IsBoolean, - IsInt, - IsNumber, - IsString, - ValidateIf, -} from 'class-validator'; - -export class SongStats { - @IsString() - midiFileName: string; - - @IsInt() - noteCount: number; - - @IsInt() - tickCount: number; - - @IsInt() - layerCount: number; - - @IsNumber() - tempo: number; - - @IsNumber() - @ValidateIf((_, value) => value !== null) - tempoRange: number[] | null; - - @IsNumber() - timeSignature: number; - - @IsNumber() - duration: number; - - @IsBoolean() - loop: boolean; - - @IsInt() - loopStartTick: number; - - @IsNumber() - minutesSpent: number; - - @IsInt() - vanillaInstrumentCount: number; - - @IsInt() - customInstrumentCount: number; - - @IsInt() - firstCustomInstrumentIndex: number; - - @IsInt() - outOfRangeNoteCount: number; - - @IsInt() - detunedNoteCount: number; - - @IsInt() - customInstrumentNoteCount: number; - - @IsInt() - incompatibleNoteCount: number; - - @IsBoolean() - compatible: boolean; - - instrumentNoteCounts: number[]; -} diff --git a/packages/database/src/song/dto/SongView.dto.ts b/packages/database/src/song/dto/SongView.dto.ts deleted file mode 100644 index 58d07c04..00000000 --- a/packages/database/src/song/dto/SongView.dto.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - IsBoolean, - IsDate, - IsNotEmpty, - IsNumber, - IsString, - IsUrl, -} from 'class-validator'; - -import { SongStats } from '../../song/dto/SongStats'; -import type { SongDocument } from '../../song/entity/song.entity'; - -import type { CategoryType, LicenseType, VisibilityType } from './types'; - -export type SongViewUploader = { - username: string; - profileImage: string; -}; - -export class SongViewDto { - @IsString() - @IsNotEmpty() - publicId: string; - - @IsDate() - @IsNotEmpty() - createdAt: Date; - - @IsNotEmpty() - uploader: SongViewUploader; - - @IsUrl() - @IsNotEmpty() - thumbnailUrl: string; - - @IsNumber() - @IsNotEmpty() - playCount: number; - - @IsNumber() - @IsNotEmpty() - downloadCount: number; - - @IsNumber() - @IsNotEmpty() - likeCount: number; - - @IsBoolean() - @IsNotEmpty() - allowDownload: boolean; - - @IsString() - @IsNotEmpty() - title: string; - - @IsString() - originalAuthor: string; - - @IsString() - description: string; - - @IsString() - @IsNotEmpty() - visibility: VisibilityType; - - @IsString() - @IsNotEmpty() - category: CategoryType; - - @IsString() - @IsNotEmpty() - license: LicenseType; - - customInstruments: string[]; - - @IsNumber() - @IsNotEmpty() - fileSize: number; - - @IsNotEmpty() - stats: SongStats; - - public static fromSongDocument(song: SongDocument): SongViewDto { - return new SongViewDto({ - publicId: song.publicId, - createdAt: song.createdAt, - uploader: song.uploader as unknown as SongViewUploader, - thumbnailUrl: song.thumbnailUrl, - playCount: song.playCount, - downloadCount: song.downloadCount, - likeCount: song.likeCount, - allowDownload: song.allowDownload, - title: song.title, - originalAuthor: song.originalAuthor, - description: song.description, - category: song.category, - visibility: song.visibility, - license: song.license, - customInstruments: song.customInstruments, - fileSize: song.fileSize, - stats: song.stats, - }); - } - - constructor(song: SongViewDto) { - Object.assign(this, song); - } -} diff --git a/packages/database/src/song/dto/ThumbnailData.dto.ts b/packages/database/src/song/dto/ThumbnailData.dto.ts deleted file mode 100644 index efc4e4ed..00000000 --- a/packages/database/src/song/dto/ThumbnailData.dto.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsHexColor, IsInt, IsNotEmpty, Max, Min } from 'class-validator'; - -import { THUMBNAIL_CONSTANTS } from '@nbw/config'; - -export class ThumbnailData { - @IsNotEmpty() - @Max(THUMBNAIL_CONSTANTS.zoomLevel.max) - @Min(THUMBNAIL_CONSTANTS.zoomLevel.min) - @IsInt() - @ApiProperty({ - description: 'Zoom level of the cover image', - example: THUMBNAIL_CONSTANTS.zoomLevel.default, - }) - zoomLevel: number; - - @IsNotEmpty() - @Min(0) - @IsInt() - @ApiProperty({ - description: 'X position of the cover image', - example: THUMBNAIL_CONSTANTS.startTick.default, - }) - startTick: number; - - @IsNotEmpty() - @Min(0) - @ApiProperty({ - description: 'Y position of the cover image', - example: THUMBNAIL_CONSTANTS.startLayer.default, - }) - startLayer: number; - - @IsNotEmpty() - @IsHexColor() - @ApiProperty({ - description: 'Background color of the cover image', - example: THUMBNAIL_CONSTANTS.backgroundColor.default, - }) - backgroundColor: string; - - static getApiExample(): ThumbnailData { - return { - zoomLevel: 3, - startTick: 0, - startLayer: 0, - backgroundColor: '#F0F0F0', - }; - } -} diff --git a/packages/database/src/song/dto/UploadSongDto.dto.ts b/packages/database/src/song/dto/UploadSongDto.dto.ts deleted file mode 100644 index 8e973050..00000000 --- a/packages/database/src/song/dto/UploadSongDto.dto.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsIn, - IsNotEmpty, - IsString, - MaxLength, - ValidateNested, -} from 'class-validator'; - -import { UPLOAD_CONSTANTS } from '@nbw/config'; - -import type { SongDocument } from '../../song/entity/song.entity'; - -import { ThumbnailData } from './ThumbnailData.dto'; -import type { CategoryType, LicenseType, VisibilityType } from './types'; - -const visibility = Object.keys(UPLOAD_CONSTANTS.visibility) as Readonly< - string[] ->; - -const categories = Object.keys(UPLOAD_CONSTANTS.categories) as Readonly< - string[] ->; - -const licenses = Object.keys(UPLOAD_CONSTANTS.licenses) as Readonly; - -export class UploadSongDto { - @ApiProperty({ - description: 'The file to upload', - - // @ts-ignore //TODO: fix this - type: 'file', - }) - file: any; //TODO: Express.Multer.File; - - @IsNotEmpty() - @IsBoolean() - @Type(() => Boolean) - @ApiProperty({ - default: true, - description: 'Whether the song can be downloaded by other users', - example: true, - }) - allowDownload: boolean; - - @IsNotEmpty() - @IsString() - @IsIn(visibility) - @ApiProperty({ - enum: visibility, - default: visibility[0], - description: 'The visibility of the song', - example: visibility[0], - }) - visibility: VisibilityType; - - @IsNotEmpty() - @IsString() - @MaxLength(UPLOAD_CONSTANTS.title.maxLength) - @ApiProperty({ - description: 'Title of the song', - example: 'My Song', - }) - title: string; - - @IsString() - @MaxLength(UPLOAD_CONSTANTS.originalAuthor.maxLength) - @ApiProperty({ - description: 'Original author of the song', - example: 'Myself', - }) - originalAuthor: string; - - @IsString() - @MaxLength(UPLOAD_CONSTANTS.description.maxLength) - @ApiProperty({ - description: 'Description of the song', - example: 'This is my song', - }) - description: string; - - @IsNotEmpty() - @IsString() - @IsIn(categories) - @ApiProperty({ - enum: categories, - description: 'Category of the song', - example: categories[0], - }) - category: CategoryType; - - @IsNotEmpty() - @ValidateNested() - @Type(() => ThumbnailData) - @Transform(({ value }) => JSON.parse(value)) - @ApiProperty({ - description: 'Thumbnail data of the song', - example: ThumbnailData.getApiExample(), - }) - thumbnailData: ThumbnailData; - - @IsNotEmpty() - @IsString() - @IsIn(licenses) - @ApiProperty({ - enum: licenses, - default: licenses[0], - description: 'The visibility of the song', - example: licenses[0], - }) - license: LicenseType; - - @IsArray() - @MaxLength(UPLOAD_CONSTANTS.customInstruments.maxCount, { each: true }) - @ApiProperty({ - description: - 'List of custom instrument paths, one for each custom instrument in the song, relative to the assets/minecraft/sounds folder', - }) - @Transform(({ value }) => JSON.parse(value)) - customInstruments: string[]; - - constructor(partial: Partial) { - Object.assign(this, partial); - } - - public static fromSongDocument(song: SongDocument): UploadSongDto { - return new UploadSongDto({ - 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 ?? [], - }); - } -} diff --git a/packages/database/src/song/dto/UploadSongResponseDto.dto.ts b/packages/database/src/song/dto/UploadSongResponseDto.dto.ts deleted file mode 100644 index b83acb2b..00000000 --- a/packages/database/src/song/dto/UploadSongResponseDto.dto.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { - IsNotEmpty, - IsString, - MaxLength, - ValidateNested, -} from 'class-validator'; - -import type { SongWithUser } from '../../song/entity/song.entity'; - -import * as SongViewDto from './SongView.dto'; -import { ThumbnailData } from './ThumbnailData.dto'; - -export class UploadSongResponseDto { - @IsString() - @IsNotEmpty() - @ApiProperty({ - description: 'ID of the song', - example: '1234567890abcdef12345678', - }) - publicId: string; - - @IsNotEmpty() - @IsString() - @MaxLength(128) - @ApiProperty({ - description: 'Title of the song', - example: 'My Song', - }) - title: string; - - @IsString() - @MaxLength(64) - @ApiProperty({ - description: 'Original author of the song', - example: 'Myself', - }) - uploader: SongViewDto.SongViewUploader; - - @IsNotEmpty() - @ValidateNested() - @Type(() => ThumbnailData) - @Transform(({ value }) => JSON.parse(value)) - @ApiProperty({ - description: 'Thumbnail data of the song', - example: ThumbnailData.getApiExample(), - }) - thumbnailUrl: string; - - @IsNotEmpty() - duration: number; - - @IsNotEmpty() - noteCount: number; - - constructor(partial: Partial) { - Object.assign(this, partial); - } - - public static fromSongWithUserDocument( - song: SongWithUser, - ): UploadSongResponseDto { - return new UploadSongResponseDto({ - publicId: song.publicId, - title: song.title, - uploader: song.uploader, - duration: song.stats.duration, - thumbnailUrl: song.thumbnailUrl, - noteCount: song.stats.noteCount, - }); - } -} diff --git a/packages/database/src/song/dto/types.ts b/packages/database/src/song/dto/types.ts deleted file mode 100644 index 2a529119..00000000 --- a/packages/database/src/song/dto/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TIMESPANS, UPLOAD_CONSTANTS } from '@nbw/config'; - -import { CustomInstrumentData } from './CustomInstrumentData.dto'; -import { FeaturedSongsDto } from './FeaturedSongsDto.dto'; -import { SongPageDto } from './SongPage.dto'; -import { SongPreviewDto } from './SongPreview.dto'; -import { SongViewDto } from './SongView.dto'; -import { ThumbnailData as ThumbnailData } from './ThumbnailData.dto'; -import { UploadSongDto } from './UploadSongDto.dto'; -import { UploadSongResponseDto } from './UploadSongResponseDto.dto'; - -export type UploadSongDtoType = InstanceType; - -export type UploadSongNoFileDtoType = Omit; - -export type UploadSongResponseDtoType = InstanceType< - typeof UploadSongResponseDto ->; - -export type SongViewDtoType = InstanceType; - -export type SongPreviewDtoType = InstanceType; - -export type SongPageDtoType = InstanceType; - -export type CustomInstrumentDataType = InstanceType< - typeof CustomInstrumentData ->; - -export type FeaturedSongsDtoType = InstanceType; - -export type ThumbnailDataType = InstanceType; - -export type VisibilityType = keyof typeof UPLOAD_CONSTANTS.visibility; - -export type CategoryType = keyof typeof UPLOAD_CONSTANTS.categories; - -export type LicenseType = keyof typeof UPLOAD_CONSTANTS.licenses; - -export type SongsFolder = Record; - -export type TimespanType = (typeof TIMESPANS)[number]; diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/song.entity.ts similarity index 82% rename from packages/database/src/song/entity/song.entity.ts rename to packages/database/src/song/song.entity.ts index 29d7bd41..91abb00d 100644 --- a/packages/database/src/song/entity/song.entity.ts +++ b/packages/database/src/song/song.entity.ts @@ -1,10 +1,15 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, Types } from 'mongoose'; -import { SongStats } from '../dto/SongStats'; -import type { SongViewUploader } from '../dto/SongView.dto'; -import { ThumbnailData } from '../dto/ThumbnailData.dto'; -import type { CategoryType, LicenseType, VisibilityType } from '../dto/types'; +import { UPLOAD_CONSTANTS } from '@nbw/config'; +import type { + CategoryType, + LicenseType, + SongStats, + SongViewUploader, + ThumbnailData, + VisibilityType, +} from '@nbw/validation'; @Schema({ timestamps: true, @@ -69,13 +74,25 @@ export class Song { @Prop({ type: Boolean, required: true, default: true }) allowDownload: boolean; - @Prop({ type: String, required: true }) + @Prop({ + type: String, + required: true, + maxlength: UPLOAD_CONSTANTS.title.maxLength, + }) title: string; - @Prop({ type: String, required: false }) + @Prop({ + type: String, + required: false, + maxlength: UPLOAD_CONSTANTS.originalAuthor.maxLength, + }) originalAuthor: string; - @Prop({ type: String, required: false }) + @Prop({ + type: String, + required: false, + maxlength: UPLOAD_CONSTANTS.description.maxLength, + }) description: string; // SONG FILE ATTRIBUTES (Populated from NBS file - immutable) diff --git a/packages/database/src/user/dto/CreateUser.dto.ts b/packages/database/src/user/dto/CreateUser.dto.ts deleted file mode 100644 index ec6ca8f8..00000000 --- a/packages/database/src/user/dto/CreateUser.dto.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsEmail, - IsNotEmpty, - IsString, - IsUrl, - MaxLength, -} from 'class-validator'; - -export class CreateUser { - @IsNotEmpty() - @IsString() - @MaxLength(64) - @IsEmail() - @ApiProperty({ - description: 'Email of the user', - example: 'vycasnicolas@gmailcom', - }) - email: string; - - @IsNotEmpty() - @IsString() - @MaxLength(64) - @ApiProperty({ - description: 'Username of the user', - example: 'tomast1137', - }) - username: string; - - @IsNotEmpty() - @IsUrl() - @ApiProperty({ - description: 'Profile image of the user', - example: 'https://example.com/image.png', - }) - profileImage: string; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} diff --git a/packages/database/src/user/dto/GetUser.dto.ts b/packages/database/src/user/dto/GetUser.dto.ts deleted file mode 100644 index 3feb46a3..00000000 --- a/packages/database/src/user/dto/GetUser.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsEmail, - IsMongoId, - IsOptional, - IsString, - MaxLength, - MinLength, -} from 'class-validator'; - -export class GetUser { - @IsString() - @IsOptional() - @MaxLength(64) - @IsEmail() - @ApiProperty({ - description: 'Email of the user', - example: 'vycasnicolas@gmailcom', - }) - email?: string; - - @IsString() - @IsOptional() - @MaxLength(64) - @ApiProperty({ - description: 'Username of the user', - example: 'tomast1137', - }) - username?: string; - - @IsString() - @IsOptional() - @MaxLength(64) - @MinLength(24) - @IsMongoId() - @ApiProperty({ - description: 'ID of the user', - example: 'replace0me6b5f0a8c1a6d8c', - }) - id?: string; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} diff --git a/packages/database/src/user/dto/Login.dto copy.ts b/packages/database/src/user/dto/Login.dto copy.ts deleted file mode 100644 index b433a0d2..00000000 --- a/packages/database/src/user/dto/Login.dto copy.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class LoginDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - public email: string; -} diff --git a/packages/database/src/user/dto/LoginWithEmail.dto.ts b/packages/database/src/user/dto/LoginWithEmail.dto.ts deleted file mode 100644 index 27c2d9cc..00000000 --- a/packages/database/src/user/dto/LoginWithEmail.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class LoginWithEmailDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - public email: string; -} diff --git a/packages/database/src/user/dto/NewEmailUser.dto.ts b/packages/database/src/user/dto/NewEmailUser.dto.ts deleted file mode 100644 index 33be8301..00000000 --- a/packages/database/src/user/dto/NewEmailUser.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsEmail, - IsNotEmpty, - IsString, - MaxLength, - MinLength, -} from 'class-validator'; - -export class NewEmailUserDto { - @ApiProperty({ - description: 'User name', - example: 'Tomast1337', - }) - @IsString() - @IsNotEmpty() - @MaxLength(64) - @MinLength(4) - username: string; - - @ApiProperty({ - description: 'User email', - example: 'vycasnicolas@gmail.com', - }) - @IsString() - @IsNotEmpty() - @MaxLength(64) - @IsEmail() - email: string; -} diff --git a/packages/database/src/user/dto/SingleUsePass.dto.ts b/packages/database/src/user/dto/SingleUsePass.dto.ts deleted file mode 100644 index e1e04c25..00000000 --- a/packages/database/src/user/dto/SingleUsePass.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class SingleUsePassDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - id: string; - - @ApiProperty() - @IsString() - @IsNotEmpty() - pass: string; -} diff --git a/packages/database/src/user/dto/UpdateUsername.dto.ts b/packages/database/src/user/dto/UpdateUsername.dto.ts deleted file mode 100644 index bc6276e8..00000000 --- a/packages/database/src/user/dto/UpdateUsername.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; - -import { USER_CONSTANTS } from '@nbw/config'; - -export class UpdateUsernameDto { - @IsString() - @MaxLength(USER_CONSTANTS.USERNAME_MAX_LENGTH) - @MinLength(USER_CONSTANTS.USERNAME_MIN_LENGTH) - @Matches(USER_CONSTANTS.ALLOWED_REGEXP) - @ApiProperty({ - description: 'Username of the user', - example: 'tomast1137', - }) - username: string; -} diff --git a/packages/database/src/user/dto/user.dto.ts b/packages/database/src/user/dto/user.dto.ts deleted file mode 100644 index a611c20f..00000000 --- a/packages/database/src/user/dto/user.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { User } from '../entity/user.entity'; - -export class UserDto { - username: string; - publicName: string; - email: string; - static fromEntity(user: User): UserDto { - const userDto: UserDto = { - username: user.username, - publicName: user.publicName, - email: user.email, - }; - - return userDto; - } -} diff --git a/packages/database/src/user/entity/user.entity.ts b/packages/database/src/user/user.entity.ts similarity index 100% rename from packages/database/src/user/entity/user.entity.ts rename to packages/database/src/user/user.entity.ts diff --git a/packages/validation/README.md b/packages/validation/README.md new file mode 100644 index 00000000..de325a22 --- /dev/null +++ b/packages/validation/README.md @@ -0,0 +1,25 @@ +# @nbw/validation + +Shared **Zod** schemas and inferred TypeScript types used across the monorepo (NestJS API, frontend, `@nbw/database`, etc.). This package is the single source of truth for request/response shapes, env validation, and related DTOs. + +## Layout + +- `src/**/*.dto.ts` — Zod schemas and exports (`z.infer` types where needed) +- `src/common/` — shared helpers (e.g. `jsonStringField`) and pagination types +- `dist/` — compiled ESM (`tsc`); consume via the package `exports` entry + +## Scripts + +From this directory (or via the workspace root with a filter): + +| Command | Description | +| --------------- | --------------------------------------------------------------------------------------------- | +| `bun run build` | Clean `dist`, emit JS (`tsconfig.build.json`), then declaration files (`tsconfig.types.json`) | +| `bun run dev` | Watch mode for JS emit | +| `bun run test` | Run `*.spec.ts` under `src/` (Bun test runner) | +| `bun run lint` | ESLint on `src/**/*.ts` | +| `bun run clean` | Remove `dist` | + +## Consumers + +Import from `@nbw/validation` after a successful build. Apps that bundle for the browser rely on **ESM** output; run `bun run build` in this package when schemas change so `dist/` stays in sync. diff --git a/packages/validation/package.json b/packages/validation/package.json new file mode 100644 index 00000000..b2f40c28 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,37 @@ +{ + "name": "@nbw/validation", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "private": true, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "bun run clean && bun run build:js && bun run build:types", + "build:js": "tsc --project tsconfig.build.json", + "build:types": "tsc --project tsconfig.types.json", + "clean": "rm -rf dist", + "dev": "tsc --project tsconfig.build.json --watch", + "lint": "eslint \"src/**/*.ts\" --fix", + "test": "bun test **/*.spec.ts" + }, + "devDependencies": { + "@types/ms": "^2.1.0", + "@types/bun": "^1.3.4", + "typescript": "^5.9.3" + }, + "dependencies": { + "ms": "^2.1.3", + "zod": "^4.1.13", + "zod-validation-error": "^5.0.0", + "@nbw/config": "workspace:*" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/packages/validation/src/auth/DiscordStrategyConfig.dto.ts b/packages/validation/src/auth/DiscordStrategyConfig.dto.ts new file mode 100644 index 00000000..0dc65512 --- /dev/null +++ b/packages/validation/src/auth/DiscordStrategyConfig.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const discordStrategyConfigSchema = z.object({ + clientID: z.string(), + clientSecret: z.string(), + callbackUrl: z.string(), + scope: z.array(z.string()), + scopeDelay: z.number().optional(), + fetchScope: z.boolean().optional(), + prompt: z.enum(['none', 'consent']), + scopeSeparator: z.string().optional(), +}); + +export type DiscordStrategyConfig = z.infer; diff --git a/packages/validation/src/common/Page.dto.ts b/packages/validation/src/common/Page.dto.ts new file mode 100644 index 00000000..32169d29 --- /dev/null +++ b/packages/validation/src/common/Page.dto.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export function createPageDtoSchema(itemSchema: T) { + return z.object({ + total: z.number().int().min(0), + page: z.number().int().min(1), + limit: z.number().int().min(1), + sort: z.string().optional(), + order: z.boolean(), + content: z.array(itemSchema), + }); +} + +export type PageDto = { + total: number; + page: number; + limit: number; + sort?: string; + order: boolean; + content: T[]; +}; diff --git a/packages/validation/src/common/PageQuery.dto.ts b/packages/validation/src/common/PageQuery.dto.ts new file mode 100644 index 00000000..e3acc67a --- /dev/null +++ b/packages/validation/src/common/PageQuery.dto.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { TIMESPANS } from '@nbw/config'; + +export const pageQueryDTOSchema = z.object({ + page: z.coerce.number().int().min(1).optional().default(1), + limit: z.coerce.number().int().min(1).max(100).optional(), + sort: z.string().optional().default('createdAt'), + order: z.enum(['asc', 'desc']).optional().default('desc'), + timespan: z.enum(TIMESPANS as unknown as [string, ...string[]]).optional(), +}); + +/** Parsed query (defaults applied). */ +export type PageQueryDTO = z.output; +/** Raw query / pre-parse shape (e.g. Nest `@Query()`). */ +export type PageQueryInput = z.input; diff --git a/packages/validation/src/common/jsonStringField.spec.ts b/packages/validation/src/common/jsonStringField.spec.ts new file mode 100644 index 00000000..79ca679f --- /dev/null +++ b/packages/validation/src/common/jsonStringField.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; + +import { z } from 'zod'; + +import { jsonStringField } from './jsonStringField'; + +describe('jsonStringField', () => { + it('parses a valid JSON string and validates against the inner schema', () => { + const schema = jsonStringField(z.array(z.string())); + const result = schema.safeParse('["a","b"]'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(['a', 'b']); + } + }); + + it('parses a JSON object string when the inner schema is an object', () => { + const schema = jsonStringField(z.object({ n: z.number(), s: z.string() })); + const result = schema.safeParse('{"n":1,"s":"x"}'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ n: 1, s: 'x' }); + } + }); + + it('turns invalid JSON into a Zod custom issue instead of throwing', () => { + const schema = jsonStringField(z.array(z.string())); + const inputs = ['{', 'not json', '', '{"unclosed": true']; + + for (const input of inputs) { + const result = schema.safeParse(input); + expect(result.success).toBe(false); + if (!result.success) { + const custom = result.error.issues.filter((i) => i.code === 'custom'); + expect(custom.length).toBeGreaterThanOrEqual(1); + expect(custom.some((i) => i.message === 'Invalid JSON string')).toBe( + true, + ); + } + } + }); + + it('does not classify valid JSON that fails the inner schema as invalid JSON', () => { + const schema = jsonStringField(z.array(z.string())); + const result = schema.safeParse('123'); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => i.message === 'Invalid JSON string'), + ).toBe(false); + } + }); + + it('rejects non-string input at the outer string schema', () => { + const schema = jsonStringField(z.array(z.string())); + const result = schema.safeParse(['already', 'an', 'array'] as unknown); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/validation/src/common/jsonStringField.ts b/packages/validation/src/common/jsonStringField.ts new file mode 100644 index 00000000..a42a23b3 --- /dev/null +++ b/packages/validation/src/common/jsonStringField.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +/** + * Multipart / form fields often arrive as JSON strings. Parses the string and + * validates with `schema`. Invalid JSON becomes a Zod issue (e.g. HTTP 400 via + * `ZodValidationPipe`) instead of a raw `SyntaxError` from `JSON.parse`. + */ +export function jsonStringField(schema: z.ZodType) { + return z + .string() + .transform((val, ctx) => { + try { + return JSON.parse(val) as unknown; + } catch { + ctx.addIssue({ + code: 'custom', + message: 'Invalid JSON string', + input: val, + }); + return z.NEVER; + } + }) + .pipe(schema); +} diff --git a/packages/validation/src/config/EnvironmentVariables.dto.ts b/packages/validation/src/config/EnvironmentVariables.dto.ts new file mode 100644 index 00000000..29ac72f0 --- /dev/null +++ b/packages/validation/src/config/EnvironmentVariables.dto.ts @@ -0,0 +1,61 @@ +import ms from 'ms'; +import { z } from 'zod'; + +const durationString = z + .string() + .refine((v) => typeof ms(v as ms.StringValue) === 'number', { + message: 'must be a valid duration string (e.g., "1h", "30m", "7d")', + }); + +export const environmentVariablesSchema = z.object({ + NODE_ENV: z.enum(['development', 'production']).optional(), + + GITHUB_CLIENT_ID: z.string(), + GITHUB_CLIENT_SECRET: z.string(), + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), + DISCORD_CLIENT_ID: z.string(), + DISCORD_CLIENT_SECRET: z.string(), + + MAGIC_LINK_SECRET: z.string(), + + JWT_SECRET: z.string(), + JWT_EXPIRES_IN: durationString, + JWT_REFRESH_SECRET: z.string(), + JWT_REFRESH_EXPIRES_IN: durationString, + + MONGO_URL: z.string(), + SERVER_URL: z.string(), + FRONTEND_URL: z.string(), + APP_DOMAIN: z.string().optional().default('localhost'), + RECAPTCHA_KEY: z.string(), + + S3_ENDPOINT: z.string(), + S3_BUCKET_SONGS: z.string(), + S3_BUCKET_THUMBS: z.string(), + S3_KEY: z.string(), + S3_SECRET: z.string(), + S3_REGION: z.string(), + WHITELISTED_USERS: z.string().optional(), + + DISCORD_WEBHOOK_URL: z.string(), + COOKIE_EXPIRES_IN: durationString, +}); + +export type EnvironmentVariables = z.output; + +export function validateEnv( + config: Record, +): EnvironmentVariables { + const result = environmentVariablesSchema.safeParse(config); + if (!result.success) { + const messages = result.error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return ` - ${path}: ${issue.message}`; + }) + .join('\n'); + throw new Error(`Environment validation failed:\n${messages}`); + } + return result.data; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts new file mode 100644 index 00000000..7796f452 --- /dev/null +++ b/packages/validation/src/index.ts @@ -0,0 +1,31 @@ +export * from './auth/DiscordStrategyConfig.dto'; +export * from './config/EnvironmentVariables.dto'; + +export * from './common/jsonStringField'; +export * from './common/Page.dto'; +export * from './common/PageQuery.dto'; + +export * from './song/CustomInstrumentData.dto'; +export * from './song/FeaturedSongsDto.dto'; +export * from './song/SongForm.dto'; +export * from './song/SongListQuery.dto'; +export * from './song/SongPage.dto'; +export * from './song/SongFileQuery.dto'; +export * from './song/SongSearchQuery.dto'; +export * from './song/SongSearchParams.dto'; +export * from './song/SongPreview.dto'; +export * from './song/SongStats'; +export * from './song/SongView.dto'; +export * from './song/ThumbnailData.dto'; +export * from './song/UploadSongDto.dto'; +export * from './song/UploadSongResponseDto.dto'; +export * from './song/uploadMeta'; + +export * from './user/CreateUser.dto'; +export * from './user/GetUser.dto'; +export * from './user/UserIndexQuery.dto'; +export * from './user/LoginWithEmail.dto'; +export * from './user/NewEmailUser.dto'; +export * from './user/SingleUsePass.dto'; +export * from './user/UpdateUsername.dto'; +export * from './user/user.dto'; diff --git a/packages/validation/src/song/CustomInstrumentData.dto.ts b/packages/validation/src/song/CustomInstrumentData.dto.ts new file mode 100644 index 00000000..ad5efb38 --- /dev/null +++ b/packages/validation/src/song/CustomInstrumentData.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const customInstrumentDataSchema = z.object({ + sound: z.array(z.string()).min(1), +}); + +export type CustomInstrumentData = z.infer; diff --git a/packages/validation/src/song/FeaturedSongsDto.dto.ts b/packages/validation/src/song/FeaturedSongsDto.dto.ts new file mode 100644 index 00000000..0ee7b5a1 --- /dev/null +++ b/packages/validation/src/song/FeaturedSongsDto.dto.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { songPreviewDtoSchema } from './SongPreview.dto'; + +export const featuredSongsDtoSchema = z.object({ + hour: z.array(songPreviewDtoSchema), + day: z.array(songPreviewDtoSchema), + week: z.array(songPreviewDtoSchema), + month: z.array(songPreviewDtoSchema), + year: z.array(songPreviewDtoSchema), + all: z.array(songPreviewDtoSchema), +}); + +export type FeaturedSongsDto = z.infer; + +export const createFeaturedSongsDto = (): FeaturedSongsDto => { + return { + hour: [], + day: [], + week: [], + month: [], + year: [], + all: [], + }; +}; diff --git a/packages/validation/src/song/SongFileQuery.dto.ts b/packages/validation/src/song/SongFileQuery.dto.ts new file mode 100644 index 00000000..ff8834a1 --- /dev/null +++ b/packages/validation/src/song/SongFileQuery.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +/** Query for `GET /song/:id/download` */ +export const songFileQueryDTOSchema = z.object({ + src: z.string(), +}); diff --git a/apps/frontend/src/modules/song/components/client/SongForm.zod.ts b/packages/validation/src/song/SongForm.dto.ts similarity index 58% rename from apps/frontend/src/modules/song/components/client/SongForm.zod.ts rename to packages/validation/src/song/SongForm.dto.ts index f6855b1f..46fcd1a3 100644 --- a/apps/frontend/src/modules/song/components/client/SongForm.zod.ts +++ b/packages/validation/src/song/SongForm.dto.ts @@ -1,25 +1,26 @@ -import { z as zod } from 'zod'; +import { z } from 'zod'; import { THUMBNAIL_CONSTANTS, UPLOAD_CONSTANTS } from '@nbw/config'; -export const thumbnailDataSchema = zod.object({ - zoomLevel: zod +/** Form defaults for thumbnail editor (API uses strict `ThumbnailData.dto`). */ +export const songFormThumbnailDataSchema = z.object({ + zoomLevel: z .number() .int() .min(THUMBNAIL_CONSTANTS.zoomLevel.min) .max(THUMBNAIL_CONSTANTS.zoomLevel.max) .default(THUMBNAIL_CONSTANTS.zoomLevel.default), - startTick: zod + startTick: z .number() .int() .min(0) .default(THUMBNAIL_CONSTANTS.startTick.default), - startLayer: zod + startLayer: z .number() .int() .min(0) .default(THUMBNAIL_CONSTANTS.startLayer.default), - backgroundColor: zod + backgroundColor: z .string() .regex(/^#[0-9a-fA-F]{6}$/) .default(THUMBNAIL_CONSTANTS.backgroundColor.default), @@ -35,11 +36,11 @@ const categories = Object.keys(UPLOAD_CONSTANTS.categories) as Readonly< const licenses = Object.keys(UPLOAD_CONSTANTS.licenses) as Readonly; -export const SongFormSchema = zod.object({ - allowDownload: zod.boolean().default(true), +export const SongFormSchema = z.object({ + allowDownload: z.boolean().default(true), - visibility: zod.enum(visibility).default('public'), - title: zod + visibility: z.enum(visibility).default('public'), + title: z .string() .max(UPLOAD_CONSTANTS.title.maxLength, { error: `Title must be shorter than ${UPLOAD_CONSTANTS.title.maxLength} characters`, @@ -47,40 +48,38 @@ export const SongFormSchema = zod.object({ .min(1, { error: 'Title is required', }), - originalAuthor: zod + originalAuthor: z .string() .max(UPLOAD_CONSTANTS.originalAuthor.maxLength, { error: `Original author must be shorter than ${UPLOAD_CONSTANTS.originalAuthor.maxLength} characters`, }) .min(0), - author: zod.string().optional(), - description: zod.string().max(UPLOAD_CONSTANTS.description.maxLength, { + author: z.string().optional(), + description: z.string().max(UPLOAD_CONSTANTS.description.maxLength, { error: `Description must be less than ${UPLOAD_CONSTANTS.description.maxLength} characters`, }), - thumbnailData: thumbnailDataSchema, - customInstruments: zod.array(zod.string()).default([]), - license: zod - .enum(['none', ...licenses] as const) + thumbnailData: songFormThumbnailDataSchema, + customInstruments: z.array(z.string()).default([]), + license: z + .enum(['none', ...(licenses as [string, ...string[]])] as [ + string, + ...string[], + ]) .refine((v) => v !== 'none', { message: 'Please select a license', }) .default(UPLOAD_CONSTANTS.license.default), - category: zod.enum(categories).default(UPLOAD_CONSTANTS.category.default), + category: z.enum(categories).default(UPLOAD_CONSTANTS.category.default), }); export const uploadSongFormSchema = SongFormSchema.extend({}); export const editSongFormSchema = SongFormSchema.extend({ - id: zod.string(), + id: z.string(), }); -// forms -export type ThumbnailDataFormInput = zod.input; -export type UploadSongFormInput = zod.input; -export type EditSongFormInput = zod.input; - -// parsed data -export type ThumbnailDataFormOutput = zod.infer; -export type UploadSongFormOutput = zod.output; -export type EditSongFormOutput = zod.output; +export type UploadSongFormInput = z.input; +export type EditSongFormInput = z.input; +export type UploadSongFormOutput = z.output; +export type EditSongFormOutput = z.output; diff --git a/packages/validation/src/song/SongListQuery.dto.ts b/packages/validation/src/song/SongListQuery.dto.ts new file mode 100644 index 00000000..1c14fa47 --- /dev/null +++ b/packages/validation/src/song/SongListQuery.dto.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export enum SongSortType { + RECENT = 'recent', + RANDOM = 'random', + PLAY_COUNT = 'playCount', + TITLE = 'title', + DURATION = 'duration', + NOTE_COUNT = 'noteCount', +} + +export enum SongOrderType { + ASC = 'asc', + DESC = 'desc', +} + +export const songListQueryDTOSchema = z.object({ + q: z.string().optional(), + sort: z.enum(SongSortType).optional().default(SongSortType.RECENT), + order: z.enum(SongOrderType).optional().default(SongOrderType.DESC), + category: z.string().optional(), + uploader: z.string().optional(), + page: z.coerce.number().int().min(1).optional().default(1), + limit: z.coerce.number().int().min(1).max(100).optional().default(10), +}); + +export type SongListQueryInput = z.input; diff --git a/packages/validation/src/song/SongPage.dto.ts b/packages/validation/src/song/SongPage.dto.ts new file mode 100644 index 00000000..369433fa --- /dev/null +++ b/packages/validation/src/song/SongPage.dto.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { songPreviewDtoSchema } from './SongPreview.dto'; + +export const songPageDtoSchema = z.object({ + content: z.array(songPreviewDtoSchema), + page: z.number().int().min(1), + limit: z.number().int().min(1), + total: z.number().int().min(0), +}); + +export type SongPageDto = z.infer; + +/** Client-side cache of loaded pages keyed by page number. */ +export type SongsFolder = Record; diff --git a/packages/validation/src/song/SongPreview.dto.ts b/packages/validation/src/song/SongPreview.dto.ts new file mode 100644 index 00000000..b95a78ba --- /dev/null +++ b/packages/validation/src/song/SongPreview.dto.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { UPLOAD_CONSTANTS } from '@nbw/config'; + +import type { VisibilityType } from './uploadMeta'; + +const songPreviewUploaderSchema = z.object({ + username: z.string(), + profileImage: z.string(), +}); + +export const songPreviewDtoSchema = z.object({ + publicId: z.string().min(1), + uploader: songPreviewUploaderSchema, + title: z.string().min(1).max(UPLOAD_CONSTANTS.title.maxLength), + description: z.string().max(UPLOAD_CONSTANTS.description.maxLength), + originalAuthor: z.string().max(UPLOAD_CONSTANTS.originalAuthor.maxLength), + duration: z.number().min(0), + noteCount: z.number().int().min(0), + thumbnailUrl: z.url(), + createdAt: z.date(), + updatedAt: z.date(), + playCount: z.number().int().min(0), + visibility: z.string() as z.ZodType, +}); + +export type SongPreviewDto = z.infer; diff --git a/packages/validation/src/song/SongSearchParams.dto.ts b/packages/validation/src/song/SongSearchParams.dto.ts new file mode 100644 index 00000000..8cdbdd52 --- /dev/null +++ b/packages/validation/src/song/SongSearchParams.dto.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { SongOrderType, SongSortType } from './SongListQuery.dto'; + +export const songSearchParamsSchema = z.object({ + q: z.string().optional(), + sort: z.enum(SongSortType).optional(), + order: z.enum(SongOrderType).optional(), + category: z.string().optional(), + uploader: z.string().optional(), + limit: z.number().int().min(1).max(100).optional(), + noteCountMin: z.number().int().min(0).optional(), + noteCountMax: z.number().int().min(0).optional(), + durationMin: z.number().int().min(0).optional(), + durationMax: z.number().int().min(0).optional(), + features: z.string().optional(), + instruments: z.string().optional(), +}); + +export type SongSearchParams = z.input; diff --git a/packages/validation/src/song/SongSearchQuery.dto.ts b/packages/validation/src/song/SongSearchQuery.dto.ts new file mode 100644 index 00000000..abc564e0 --- /dev/null +++ b/packages/validation/src/song/SongSearchQuery.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { pageQueryDTOSchema } from '../common/PageQuery.dto.js'; + +export const songSearchQueryDTOSchema = pageQueryDTOSchema.extend({ + q: z.string().optional().default(''), +}); + +export type SongSearchQueryInput = z.input; diff --git a/packages/validation/src/song/SongStats.ts b/packages/validation/src/song/SongStats.ts new file mode 100644 index 00000000..6e1b9b06 --- /dev/null +++ b/packages/validation/src/song/SongStats.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const songStatsSchema = z.object({ + midiFileName: z.string(), + noteCount: z.number().int(), + tickCount: z.number().int(), + layerCount: z.number().int(), + tempo: z.number(), + tempoRange: z.array(z.number()).nullable(), + timeSignature: z.number(), + duration: z.number(), + loop: z.boolean(), + loopStartTick: z.number().int(), + minutesSpent: z.number(), + vanillaInstrumentCount: z.number().int(), + customInstrumentCount: z.number().int(), + firstCustomInstrumentIndex: z.number().int(), + outOfRangeNoteCount: z.number().int(), + detunedNoteCount: z.number().int(), + customInstrumentNoteCount: z.number().int(), + incompatibleNoteCount: z.number().int(), + compatible: z.boolean(), + instrumentNoteCounts: z.array(z.number().int()), +}); + +export type SongStats = z.infer; diff --git a/packages/validation/src/song/SongView.dto.ts b/packages/validation/src/song/SongView.dto.ts new file mode 100644 index 00000000..ce8e40d7 --- /dev/null +++ b/packages/validation/src/song/SongView.dto.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { songStatsSchema } from './SongStats'; +import type { CategoryType, LicenseType, VisibilityType } from './uploadMeta'; + +export const songViewUploaderSchema = z.object({ + username: z.string(), + profileImage: z.string(), +}); + +export type SongViewUploader = z.infer; + +export const songViewDtoSchema = z.object({ + publicId: z.string().min(1), + createdAt: z.date(), + uploader: songViewUploaderSchema, + thumbnailUrl: z.url(), + playCount: z.number().int().min(0), + downloadCount: z.number().int().min(0), + likeCount: z.number().int().min(0), + allowDownload: z.boolean(), + title: z.string().min(1), + originalAuthor: z.string(), + description: z.string(), + visibility: z.string() as z.ZodType, + category: z.string() as z.ZodType, + license: z.string() as z.ZodType, + customInstruments: z.array(z.string()), + fileSize: z.number().int().min(0), + stats: songStatsSchema, +}); + +export type SongViewDto = z.infer; diff --git a/packages/validation/src/song/ThumbnailData.dto.ts b/packages/validation/src/song/ThumbnailData.dto.ts new file mode 100644 index 00000000..003a2d5d --- /dev/null +++ b/packages/validation/src/song/ThumbnailData.dto.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { THUMBNAIL_CONSTANTS } from '@nbw/config'; + +export const thumbnailDataSchema = z.object({ + zoomLevel: z + .number() + .int() + .min(THUMBNAIL_CONSTANTS.zoomLevel.min) + .max(THUMBNAIL_CONSTANTS.zoomLevel.max), + startTick: z.number().int().min(0), + startLayer: z.number().int().min(0), + backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/), +}); + +export type ThumbnailData = z.infer; diff --git a/packages/validation/src/song/UploadSongDto.dto.ts b/packages/validation/src/song/UploadSongDto.dto.ts new file mode 100644 index 00000000..d0df496a --- /dev/null +++ b/packages/validation/src/song/UploadSongDto.dto.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +import { UPLOAD_CONSTANTS } from '@nbw/config'; + +import { jsonStringField } from '../common/jsonStringField'; + +import { thumbnailDataSchema } from './ThumbnailData.dto'; +import type { CategoryType, LicenseType, VisibilityType } from './uploadMeta'; + +const visibility = Object.keys(UPLOAD_CONSTANTS.visibility) as Readonly< + string[] +>; + +const categories = Object.keys(UPLOAD_CONSTANTS.categories) as Readonly< + string[] +>; + +const licenses = Object.keys(UPLOAD_CONSTANTS.licenses) as Readonly; + +// Note: file field is not validated by zod as it's handled by multer/file upload middleware +export const uploadSongDtoSchema = z.object({ + file: z.any(), // Express.Multer.File - handled by upload middleware + allowDownload: z + .union([z.boolean(), z.string().transform((val) => val === 'true')]) + .pipe(z.boolean()), + visibility: z.enum( + visibility as [string, ...string[]], + ) as z.ZodType, + title: z.string().min(1).max(UPLOAD_CONSTANTS.title.maxLength), + originalAuthor: z.string().max(UPLOAD_CONSTANTS.originalAuthor.maxLength), + description: z.string().max(UPLOAD_CONSTANTS.description.maxLength), + category: z.enum( + categories as [string, ...string[]], + ) as z.ZodType, + thumbnailData: z + .union([thumbnailDataSchema, jsonStringField(thumbnailDataSchema)]) + .pipe(thumbnailDataSchema), + license: z.enum(licenses as [string, ...string[]]) as z.ZodType, + customInstruments: z + .union([z.array(z.string()), jsonStringField(z.array(z.string()))]) + .pipe(z.array(z.string()).max(UPLOAD_CONSTANTS.customInstruments.maxCount)), +}); + +export type UploadSongDto = z.infer; diff --git a/packages/validation/src/song/UploadSongResponseDto.dto.ts b/packages/validation/src/song/UploadSongResponseDto.dto.ts new file mode 100644 index 00000000..bfd0c1e5 --- /dev/null +++ b/packages/validation/src/song/UploadSongResponseDto.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { songViewUploaderSchema } from './SongView.dto'; + +export const uploadSongResponseDtoSchema = z.object({ + publicId: z.string().min(1), + title: z.string().min(1).max(128), + uploader: songViewUploaderSchema, + thumbnailUrl: z.url(), + duration: z.number().min(0), + noteCount: z.number().int().min(0), +}); + +export type UploadSongResponseDto = z.infer; diff --git a/packages/validation/src/song/uploadMeta.ts b/packages/validation/src/song/uploadMeta.ts new file mode 100644 index 00000000..43b68008 --- /dev/null +++ b/packages/validation/src/song/uploadMeta.ts @@ -0,0 +1,9 @@ +import { TIMESPANS, UPLOAD_CONSTANTS } from '@nbw/config'; + +/** Keys of the upload visibility / category / license maps from config. */ +export type VisibilityType = keyof typeof UPLOAD_CONSTANTS.visibility; +export type CategoryType = keyof typeof UPLOAD_CONSTANTS.categories; +export type LicenseType = keyof typeof UPLOAD_CONSTANTS.licenses; + +/** Featured-songs buckets (hour / day / week / …) — matches `pageQueryDTOSchema` `timespan`. */ +export type TimespanType = (typeof TIMESPANS)[number]; diff --git a/packages/validation/src/user/CreateUser.dto.ts b/packages/validation/src/user/CreateUser.dto.ts new file mode 100644 index 00000000..9eda7bfc --- /dev/null +++ b/packages/validation/src/user/CreateUser.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const createUserSchema = z.object({ + email: z.string().email().max(64).min(1), + username: z.string().max(64).min(1), + profileImage: z.string().url(), +}); + +export type CreateUser = z.infer; diff --git a/packages/validation/src/user/GetUser.dto.ts b/packages/validation/src/user/GetUser.dto.ts new file mode 100644 index 00000000..8453d48f --- /dev/null +++ b/packages/validation/src/user/GetUser.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const getUserSchema = z.object({ + email: z.string().email().max(64).optional(), + username: z.string().max(64).optional(), + id: z + .string() + .regex(/^[0-9a-fA-F]{24}$/) + .min(24) + .max(24) + .optional(), +}); + +export type GetUser = z.infer; diff --git a/packages/validation/src/user/LoginWithEmail.dto.ts b/packages/validation/src/user/LoginWithEmail.dto.ts new file mode 100644 index 00000000..69250ecf --- /dev/null +++ b/packages/validation/src/user/LoginWithEmail.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const loginWithEmailDtoSchema = z.object({ + email: z.string().email().min(1), +}); + +export type LoginWithEmailDto = z.infer; + +/** @deprecated Use loginWithEmailDtoSchema */ +export const loginDtoSchema = loginWithEmailDtoSchema; +/** @deprecated Use LoginWithEmailDto */ +export type LoginDto = LoginWithEmailDto; diff --git a/packages/validation/src/user/NewEmailUser.dto.ts b/packages/validation/src/user/NewEmailUser.dto.ts new file mode 100644 index 00000000..cd71645e --- /dev/null +++ b/packages/validation/src/user/NewEmailUser.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const newEmailUserDtoSchema = z.object({ + username: z.string().min(4).max(64), + email: z.string().email().max(64).min(1), +}); + +export type NewEmailUserDto = z.infer; diff --git a/packages/validation/src/user/SingleUsePass.dto.ts b/packages/validation/src/user/SingleUsePass.dto.ts new file mode 100644 index 00000000..b827a726 --- /dev/null +++ b/packages/validation/src/user/SingleUsePass.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const singleUsePassDtoSchema = z.object({ + id: z.string().min(1), + pass: z.string().min(1), +}); + +export type SingleUsePassDto = z.infer; diff --git a/packages/validation/src/user/UpdateUsername.dto.ts b/packages/validation/src/user/UpdateUsername.dto.ts new file mode 100644 index 00000000..b6ea0a84 --- /dev/null +++ b/packages/validation/src/user/UpdateUsername.dto.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { USER_CONSTANTS } from '@nbw/config'; + +export const updateUsernameDtoSchema = z.object({ + username: z + .string() + .min(USER_CONSTANTS.USERNAME_MIN_LENGTH) + .max(USER_CONSTANTS.USERNAME_MAX_LENGTH) + .regex(USER_CONSTANTS.ALLOWED_REGEXP), +}); + +export type UpdateUsernameDto = z.infer; diff --git a/packages/validation/src/user/UserIndexQuery.dto.ts b/packages/validation/src/user/UserIndexQuery.dto.ts new file mode 100644 index 00000000..17afd328 --- /dev/null +++ b/packages/validation/src/user/UserIndexQuery.dto.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { + pageQueryDTOSchema, + type PageQueryInput, +} from '../common/PageQuery.dto'; + +import { getUserSchema } from './GetUser.dto'; + +/** + * `GET /user` query: always paginated, optionally filtered by email/id/username. + */ +export const userIndexQuerySchema = pageQueryDTOSchema.extend({ + email: getUserSchema.shape.email.optional(), + id: getUserSchema.shape.id.optional(), + username: getUserSchema.shape.username.optional(), +}); + +export type UserIndexQuery = z.output; +export type UserIndexQueryInput = z.input; +export type UserIndexPageQueryInput = PageQueryInput & { + email?: string; + id?: string; + username?: string; +}; diff --git a/packages/validation/src/user/user.dto.ts b/packages/validation/src/user/user.dto.ts new file mode 100644 index 00000000..42626de8 --- /dev/null +++ b/packages/validation/src/user/user.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const userDtoSchema = z.object({ + username: z.string(), + publicName: z.string(), + email: z.string(), +}); + +export type UserDto = z.infer; diff --git a/packages/validation/tsconfig.build.json b/packages/validation/tsconfig.build.json new file mode 100644 index 00000000..acd57081 --- /dev/null +++ b/packages/validation/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Unlike other packages where we use a bundler to output JS, + // this package uses tsc for its build step. + // We must disable the default 'composite' config to output JS. + "composite": false, + "declaration": false, + "emitDeclarationOnly": false, + + // ESM output: matches package.json "type": "module" and works in Next.js browser bundles. + // CommonJS dist was evaluated as ESM in the client, causing "exports is not defined". + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2021", + + "esModuleInterop": true, + + // Allow ES imports + "verbatimModuleSyntax": false + }, + "include": ["src/**/*"], + "exclude": ["**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json new file mode 100644 index 00000000..9547fe19 --- /dev/null +++ b/packages/validation/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.package.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo", + + // Database runtime requirements + "emitDecoratorMetadata": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/validation/tsconfig.types.json b/packages/validation/tsconfig.types.json new file mode 100644 index 00000000..be175bda --- /dev/null +++ b/packages/validation/tsconfig.types.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Emit-only configuration for building types. + "declaration": true, + "emitDeclarationOnly": true + } +} diff --git a/scripts/build.ts b/scripts/build.ts index 72961906..2ad5a447 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -4,6 +4,7 @@ import { $ } from 'bun'; // the sub array is for packages that can be built in parallel const packages: (string | string[])[] = [ '@nbw/config', + '@nbw/validation', '@nbw/database', '@nbw/song', '@nbw/sounds', diff --git a/tsconfig.base.json b/tsconfig.base.json index b52ee70a..1abf19b2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,7 +47,7 @@ // ============================== // Required for libraries relying on legacy decorators and runtime metadata - // (e.g. class-validator). + // (e.g. Nest/Mongoose decorators). "experimentalDecorators": true, "emitDecoratorMetadata": true } diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index a627c79a..c1531054 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -15,6 +15,7 @@ { "path": "packages/database" }, { "path": "packages/song" }, { "path": "packages/sounds" }, - { "path": "packages/thumbnail" } + { "path": "packages/thumbnail" }, + { "path": "packages/validation" } ] } diff --git a/tsconfig.json b/tsconfig.json index 48dd729b..143c6da3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "./tsconfig.base.json", // Root build orchestrator. // This file does NOT compile code - only defines project references for `tsc -b`. // Real projects live in /apps and /packages and extend tsconfig.base.json. @@ -16,6 +17,7 @@ { "path": "packages/database" }, { "path": "packages/song" }, { "path": "packages/sounds" }, - { "path": "packages/thumbnail" } + { "path": "packages/thumbnail" }, + { "path": "packages/validation" } ] }