diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 392f420f..2f2b724d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,22 +21,37 @@ You'll need the following installed on your machine: - [Docker](https://www.docker.com/) - [Docker Compose](https://docs.docker.com/compose/) -We provide a `docker-compose-dev.yml` file that sets up: +We provide [`docker-compose.yml`](docker-compose.yml) at the repository root. It defines: -- A MongoDB instance -- A local mail server (`maildev`) -- An S3-compatible storage (`minio`) -- A MinIO client +- **MongoDB** +- **MailDev** (SMTP + web UI for local email) +- **MinIO** (S3-compatible storage) +- A **MinIO client** job (profile `minio-init`) that creates buckets and CORS—**not** started by a plain `docker compose up` -To start the services, run the following in the root directory: +Start dependencies from the repo root in one of these ways: + +**Recommended** (waits for MongoDB and MinIO to be healthy, then runs bucket setup): + +```bash +bun run docker:up +``` + +**Manual** (then create buckets once; uploads will fail with `NoSuchBucket` until you do): + +```bash +docker compose up -d +bun run docker:minio-init +``` + +To tear down volumes and start clean (still runs MinIO init after `up --wait`): ```bash -docker-compose -f docker-compose.yml up -d +bun run docker:reset:fresh ``` -> Remove the `-d` flag if you'd like to see container logs in your terminal. +> Drop `-d` on `docker compose up -d` if you prefer logs attached to your terminal. -You can find authentication details in the [`docker-compose.yml`](docker-compose.yml) file. +Ports and default credentials (Mongo, MinIO, MailDev) are in [`docker-compose.yml`](docker-compose.yml). Match the MinIO bucket names and keys in your backend `.env` (see the example block under **Environment Variables** below). --- diff --git a/apps/backend/.env.development.example b/apps/backend/.env.development.example index e7196871..bae2e827 100644 --- a/apps/backend/.env.development.example +++ b/apps/backend/.env.development.example @@ -1,5 +1,9 @@ NODE_ENV= +# Optional: non-empty value enables `POST /v1/auth/e2e/session` (dev only) for Cypress — +# same value as Cypress env `E2E_AUTH_SECRET` / `CYPRESS_E2E_AUTH_SECRET`. +E2E_AUTH_SECRET= + GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= @@ -23,6 +27,15 @@ APP_DOMAIN= RECAPTCHA_KEY= +# MinIO from repo docker-compose: after `docker compose up -d` (or `bun run docker:up`), +# create buckets once with `bun run docker:minio-init` from the monorepo root (see root package.json). +# Example local values (match docker-compose mc bucket names): +# S3_ENDPOINT=http://localhost:9000 +# S3_BUCKET_SONGS=noteblockworld-songs +# S3_BUCKET_THUMBS=noteblockworld-thumbs +# S3_KEY=minioadmin +# S3_SECRET=minioadmin +# S3_REGION=us-east-1 S3_ENDPOINT= S3_BUCKET_SONGS= S3_BUCKET_THUMBS= 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.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index 0a68ba35..479cbe1e 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -1,6 +1,10 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import type { Request, Response } from 'express'; +import { UserService } from '@server/user/user.service'; + import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; @@ -11,6 +15,16 @@ const mockAuthService = { discordLogin: jest.fn(), verifyToken: jest.fn(), loginWithEmail: jest.fn(), + issueSessionTokensForUser: jest.fn(), +}; + +const mockUserService = { + findByEmail: jest.fn(), + findByID: jest.fn(), +}; + +const mockConfigService = { + get: jest.fn(), }; const mockMagicLinkEmailStrategy = { @@ -36,6 +50,8 @@ describe('AuthController', () => { provide: MagicLinkEmailStrategy, useValue: mockMagicLinkEmailStrategy, }, + { provide: UserService, useValue: mockUserService }, + { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); @@ -192,4 +208,120 @@ describe('AuthController', () => { expect(authService.verifyToken).toHaveBeenCalledWith(req, res); }); }); + + describe('e2eSession', () => { + it('returns 404 when not in development', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'E2E_AUTH_SECRET') return 'secret'; + return undefined; + }); + + await expect( + controller.e2eSession('secret', { email: 'a@b.c' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns 404 when E2E_AUTH_SECRET is empty', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return ''; + return undefined; + }); + + await expect( + controller.e2eSession('secret', { email: 'a@b.c' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns 404 when header secret is wrong', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return 'good'; + return undefined; + }); + + await expect( + controller.e2eSession('bad', { email: 'a@b.c' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns 400 when both email and userId are provided', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return 's'; + return undefined; + }); + + await expect( + controller.e2eSession('s', { email: 'a@b.c', userId: 'id' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns 400 when neither email nor userId', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return 's'; + return undefined; + }); + + await expect(controller.e2eSession('s', {})).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('returns tokens for existing user by email', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return 's'; + return undefined; + }); + const user = { _id: 'u1', email: 'e@e.com', username: 'u' }; + mockUserService.findByEmail.mockResolvedValueOnce(user); + mockAuthService.issueSessionTokensForUser.mockResolvedValueOnce({ + access_token: 'a', + refresh_token: 'r', + }); + + const out = await controller.e2eSession('s', { email: 'e@e.com' }); + + expect(out).toEqual({ access_token: 'a', refresh_token: 'r' }); + expect(mockUserService.findByEmail).toHaveBeenCalledWith('e@e.com'); + expect(mockAuthService.issueSessionTokensForUser).toHaveBeenCalledWith( + user, + ); + }); + + it('returns tokens for existing user by userId', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return 's'; + return undefined; + }); + const user = { _id: 'u1', email: 'e@e.com', username: 'u' }; + mockUserService.findByID.mockResolvedValueOnce(user); + mockAuthService.issueSessionTokensForUser.mockResolvedValueOnce({ + access_token: 'a', + refresh_token: 'r', + }); + + const out = await controller.e2eSession('s', { userId: 'abc' }); + + expect(out).toEqual({ access_token: 'a', refresh_token: 'r' }); + expect(mockUserService.findByID).toHaveBeenCalledWith('abc'); + }); + + it('returns 404 when user is not found', async () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'E2E_AUTH_SECRET') return 's'; + return undefined; + }); + mockUserService.findByEmail.mockResolvedValueOnce(null); + + await expect( + controller.e2eSession('s', { email: 'missing@x.com' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); }); diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index fbc1afbb..956741ae 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -1,20 +1,34 @@ import { + BadRequestException, + Body, Controller, Get, + Headers, + HttpCode, HttpException, HttpStatus, Inject, Logger, + NotFoundException, Post, Req, Res, UseGuards, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { AuthGuard } from '@nestjs/passport'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { + ApiExcludeEndpoint, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; +import { E2E_AUTH_HEADER } from '@nbw/config'; +import { UserService } from '@server/user/user.service'; + import { AuthService } from './auth.service'; import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; @@ -27,6 +41,9 @@ export class AuthController { private readonly authService: AuthService, @Inject(MagicLinkEmailStrategy) private readonly magicLinkEmailStrategy: MagicLinkEmailStrategy, + @Inject(UserService) + private readonly userService: UserService, + private readonly configService: ConfigService, ) {} @Throttle({ @@ -119,6 +136,50 @@ export class AuthController { return this.authService.discordLogin(req, res); } + /** + * Development-only: mint JWT pair for an existing user so Cypress can `setCookie` + * without UI login. Disabled unless `NODE_ENV === 'development'` and + * `E2E_AUTH_SECRET` is non-empty; wrong/missing secret returns 404 to avoid + * advertising the route. + */ + @Post('e2e/session') + @HttpCode(200) + @SkipThrottle() + @ApiExcludeEndpoint() + public async e2eSession( + @Headers(E2E_AUTH_HEADER) secret: string | undefined, + @Body() body: { email?: string; userId?: string }, + ): Promise<{ access_token: string; refresh_token: string }> { + const nodeEnv = this.configService.get('NODE_ENV'); + const expectedSecret = this.configService.get('E2E_AUTH_SECRET'); + if ( + nodeEnv !== 'development' || + !expectedSecret || + expectedSecret.length === 0 + ) { + throw new NotFoundException(); + } + if (!secret || secret !== expectedSecret) { + throw new NotFoundException(); + } + + const email = body?.email?.trim(); + const userId = body?.userId?.trim(); + if ((email ? 1 : 0) + (userId ? 1 : 0) !== 1) { + throw new BadRequestException('Provide exactly one of email or userId'); + } + + const user = email + ? await this.userService.findByEmail(email) + : await this.userService.findByID(userId!); + + if (!user) { + throw new NotFoundException(); + } + + return this.authService.issueSessionTokensForUser(user); + } + @Get('verify') @ApiOperation({ summary: 'Verify user token' }) @ApiResponse({ status: 200, description: 'User token verified' }) diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index add4d1dc..5aa64337 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -185,6 +185,33 @@ describe('AuthService', () => { }); }); + describe('issueSessionTokensForUser', () => { + it('should delegate to createJwtPayload with user fields', async () => { + const user = { + _id: { toString: () => 'oid-1' }, + email: 'e@e.com', + username: 'user1', + } as unknown as UserDocument; + + spyOn(authService as any, 'createJwtPayload').mockResolvedValueOnce({ + access_token: 'a', + refresh_token: 'r', + }); + + const tokens = await authService.issueSessionTokensForUser(user); + + expect(tokens).toEqual({ + access_token: 'a', + refresh_token: 'r', + }); + expect((authService as any).createJwtPayload).toHaveBeenCalledWith({ + id: 'oid-1', + email: 'e@e.com', + username: 'user1', + }); + }); + }); + describe('createJwtPayload', () => { it('should create access and refresh tokens', async () => { const payload = { id: 'user-id', username: 'testuser' }; @@ -278,6 +305,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..c543a542 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, @@ -170,6 +169,17 @@ export class AuthService { return this.GenTokenRedirect(user, res); } + /** Mint access + refresh JWTs for an existing user (same payload shape as OAuth callbacks). */ + public issueSessionTokensForUser( + user_registered: UserDocument, + ): Promise { + return this.createJwtPayload({ + id: user_registered._id.toString(), + email: user_registered.email, + username: user_registered.username, + }); + } + public async createJwtPayload(payload: TokenPayload): Promise { const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(payload, { @@ -220,8 +230,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.controller.spec.ts b/apps/backend/src/seed/seed.controller.spec.ts index 63cc91ea..37032786 100644 --- a/apps/backend/src/seed/seed.controller.spec.ts +++ b/apps/backend/src/seed/seed.controller.spec.ts @@ -1,26 +1,141 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { + DEFAULT_SEED_DATA_TIME_CAP, + DEFAULT_SEED_FAKER, + SEED_USER_COUNT_MAX, +} from '@nbw/config'; + import { SeedController } from './seed.controller'; import { SeedService } from './seed.service'; describe('SeedController', () => { let controller: SeedController; + let seedService: { seedDev: jest.Mock }; + + async function createController(nodeEnv: string) { + seedService = { seedDev: jest.fn().mockResolvedValue(undefined) }; - beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SeedController], providers: [ - { - provide: SeedService, - useValue: {}, - }, + { provide: SeedService, useValue: seedService }, + { provide: 'NODE_ENV', useValue: nodeEnv }, ], }).compile(); - controller = module.get(SeedController); + return module.get(SeedController); + } + + beforeEach(async () => { + controller = await createController('development'); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('seed', () => { + it('calls seedDev with defaults when query params are omitted', async () => { + const result = await controller.seed(undefined, undefined, undefined); + + expect(seedService.seedDev).toHaveBeenCalledTimes(1); + expect(seedService.seedDev).toHaveBeenLastCalledWith({ + fakerSeed: DEFAULT_SEED_FAKER, + userCount: 100, + }); + expect(result).toMatchObject({ + message: 'Seeding complete', + fakerSeed: DEFAULT_SEED_FAKER, + userCount: 100, + createdAtUpper: DEFAULT_SEED_DATA_TIME_CAP.toISOString(), + }); + }); + + it('parses fakerSeed and userCount from query strings', async () => { + await controller.seed('7', '12', undefined); + + expect(seedService.seedDev).toHaveBeenLastCalledWith({ + fakerSeed: 7, + userCount: 12, + }); + }); + + it('passes createdAtUpper when a valid ISO string is provided', async () => { + const iso = '2024-03-01T15:30:00.000Z'; + await controller.seed(undefined, undefined, iso); + + expect(seedService.seedDev).toHaveBeenLastCalledWith({ + fakerSeed: DEFAULT_SEED_FAKER, + userCount: 100, + createdAtUpper: new Date(iso), + }); + }); + + it('clamps userCount to the configured maximum', async () => { + await controller.seed( + undefined, + String(SEED_USER_COUNT_MAX + 999), + undefined, + ); + + expect(seedService.seedDev).toHaveBeenLastCalledWith({ + fakerSeed: DEFAULT_SEED_FAKER, + userCount: SEED_USER_COUNT_MAX, + }); + }); + + it('clamps userCount to at least 1', async () => { + await controller.seed(undefined, '0', undefined); + + expect(seedService.seedDev).toHaveBeenLastCalledWith({ + fakerSeed: DEFAULT_SEED_FAKER, + userCount: 1, + }); + }); + + it('throws BadRequest when fakerSeed is not an integer', async () => { + await expect( + controller.seed('not-int', undefined, undefined), + ).rejects.toThrow(BadRequestException); + await expect( + controller.seed('not-int', undefined, undefined), + ).rejects.toThrow('fakerSeed must be an integer'); + expect(seedService.seedDev).not.toHaveBeenCalled(); + }); + + it('throws BadRequest when userCount is not an integer', async () => { + await expect(controller.seed(undefined, 'x', undefined)).rejects.toThrow( + BadRequestException, + ); + expect(seedService.seedDev).not.toHaveBeenCalled(); + }); + + it('throws BadRequest when createdAtUpper is not a valid date', async () => { + await expect( + controller.seed(undefined, undefined, 'not-a-date'), + ).rejects.toThrow(BadRequestException); + await expect( + controller.seed(undefined, undefined, 'not-a-date'), + ).rejects.toThrow('createdAtUpper must be a valid ISO 8601 date string'); + expect(seedService.seedDev).not.toHaveBeenCalled(); + }); + + it('throws BadRequest when NODE_ENV is not development', async () => { + const prodController = await createController('production'); + + await expect( + prodController.seed(undefined, undefined, undefined), + ).rejects.toThrow(BadRequestException); + await expect( + prodController.seed(undefined, undefined, undefined), + ).rejects.toThrow('Seeding is only allowed in development mode'); + expect(seedService.seedDev).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/seed/seed.controller.ts b/apps/backend/src/seed/seed.controller.ts index ae82f980..7abbe534 100644 --- a/apps/backend/src/seed/seed.controller.ts +++ b/apps/backend/src/seed/seed.controller.ts @@ -1,21 +1,131 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + BadRequestException, + Controller, + Get, + Inject, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; + +import { + DEFAULT_SEED_DATA_TIME_CAP, + DEFAULT_SEED_FAKER, + SEED_USER_COUNT_MAX, + SEED_USER_COUNT_MIN, + type SeedDevOptions, +} from '@nbw/config'; import { SeedService } from './seed.service'; @Controller('seed') @ApiTags('seed') export class SeedController { - constructor(private readonly seedService: SeedService) {} + constructor( + private readonly seedService: SeedService, + @Inject('NODE_ENV') + private readonly NODE_ENV: string, + ) {} @Get('seed-dev') @ApiOperation({ - summary: 'Seed the database with development data', + summary: + 'Seed the database with development data (deterministic by default)', + description: + 'Uses a fixed Faker seed and stable user emails unless overridden. ' + + 'Use a fresh database or expect "email already registered" on repeat. ' + + 'Song `publicId` values still use random nanoids from the upload pipeline.', + }) + @ApiQuery({ + name: 'fakerSeed', + required: false, + type: Number, + example: DEFAULT_SEED_FAKER, + }) + @ApiQuery({ + name: 'userCount', + required: false, + type: Number, + example: 20, + description: `Clamped to ${SEED_USER_COUNT_MIN}–${SEED_USER_COUNT_MAX}.`, }) - async seed() { - this.seedService.seedDev(); + @ApiQuery({ + name: 'createdAtUpper', + required: false, + type: String, + example: DEFAULT_SEED_DATA_TIME_CAP.toISOString(), + description: 'ISO 8601 upper bound for random user/song createdAt.', + }) + async seed( + @Query('fakerSeed') fakerSeedRaw?: string, + @Query('userCount') userCountRaw?: string, + @Query('createdAtUpper') createdAtUpperIso?: string, + ) { + if (this.NODE_ENV !== 'development') { + throw new BadRequestException( + 'Seeding is only allowed in development mode', + ); + } + const fakerSeed = parseOptionalIntQuery( + fakerSeedRaw, + DEFAULT_SEED_FAKER, + 'fakerSeed', + ); + const userCount = parseOptionalIntQuery( + userCountRaw, + 100, + 'userCount', + SEED_USER_COUNT_MIN, + SEED_USER_COUNT_MAX, + ); + + const options: SeedDevOptions = { fakerSeed, userCount }; + + if (createdAtUpperIso !== undefined && createdAtUpperIso !== '') { + const d = new Date(createdAtUpperIso); + if (Number.isNaN(d.getTime())) { + throw new BadRequestException( + 'createdAtUpper must be a valid ISO 8601 date string', + ); + } + options.createdAtUpper = d; + } + + await this.seedService.seedDev(options); + return { - message: 'Seeding in progress', + message: 'Seeding complete', + fakerSeed: options.fakerSeed, + userCount: options.userCount, + createdAtUpper: ( + options.createdAtUpper ?? DEFAULT_SEED_DATA_TIME_CAP + ).toISOString(), }; } } + +function parseOptionalIntQuery( + raw: string | undefined, + fallback: number, + name: string, + min?: number, + max?: number, +): number { + if (raw === undefined || raw === '') { + return fallback; + } + + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n)) { + throw new BadRequestException(`${name} must be an integer`); + } + + let v = Math.trunc(n); + if (min !== undefined) { + v = Math.max(min, v); + } + if (max !== undefined) { + v = Math.min(max, v); + } + + return v; +} diff --git a/apps/backend/src/seed/seed.service.spec.ts b/apps/backend/src/seed/seed.service.spec.ts index 7577e9c4..70526dde 100644 --- a/apps/backend/src/seed/seed.service.spec.ts +++ b/apps/backend/src/seed/seed.service.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { DEFAULT_SEED_FAKER } from '@nbw/config'; +import type { UserDocument } from '@nbw/database'; import { SongService } from '@server/song/song.service'; import { UserService } from '@server/user/user.service'; @@ -7,35 +9,93 @@ import { SeedService } from './seed.service'; describe('SeedService', () => { let service: SeedService; + let userService: { + createWithEmail: jest.Mock; + update: jest.Mock; + }; + let songService: { + uploadSong: jest.Mock; + getSongById: jest.Mock; + }; beforeEach(async () => { + userService = { + createWithEmail: jest.fn().mockImplementation(async (email: string) => { + return { + email, + socialLinks: {}, + } as unknown as UserDocument; + }), + update: jest.fn().mockImplementation(async (user: UserDocument) => user), + }; + + songService = { + uploadSong: jest + .fn() + .mockResolvedValue({ publicId: 'seed-test-public-id' }), + getSongById: jest.fn().mockResolvedValue({ + publicId: 'seed-test-public-id', + save: jest.fn().mockResolvedValue(undefined), + }), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ SeedService, - { - provide: 'NODE_ENV', - useValue: 'development', - }, - { - provide: UserService, - useValue: { - createWithPassword: jest.fn(), - }, - }, - { - provide: SongService, - useValue: { - uploadSong: jest.fn(), - getSongById: jest.fn(), - }, - }, + { provide: UserService, useValue: userService }, + { provide: SongService, useValue: songService }, ], }).compile(); service = module.get(SeedService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('seedDev', () => { + it('creates users with deterministic emails and finishes without error', async () => { + await expect( + service.seedDev({ userCount: 2, fakerSeed: 99_999 }), + ).resolves.toBeUndefined(); + + expect(userService.createWithEmail).toHaveBeenCalledTimes(2); + expect(userService.createWithEmail).toHaveBeenNthCalledWith( + 1, + 'nbw-seed-0000@seed.noteblockworld.test', + ); + expect(userService.createWithEmail).toHaveBeenNthCalledWith( + 2, + 'nbw-seed-0001@seed.noteblockworld.test', + ); + expect(userService.update).toHaveBeenCalledTimes(2); + }); + + it('uses default faker seed when omitted', async () => { + await service.seedDev({ userCount: 1 }); + + expect(userService.createWithEmail).toHaveBeenCalledTimes(1); + expect(userService.update).toHaveBeenCalledTimes(1); + }); + + it('clamps userCount to configured bounds', async () => { + await service.seedDev({ userCount: 0, fakerSeed: 1 }); + + expect(userService.createWithEmail).toHaveBeenCalledTimes(1); + }); + + it('uploads songs via SongService when the random song count is non-zero', async () => { + await service.seedDev({ userCount: 25, fakerSeed: DEFAULT_SEED_FAKER }); + + expect(songService.uploadSong.mock.calls.length).toBeGreaterThan(0); + expect(songService.getSongById.mock.calls.length).toBe( + songService.uploadSong.mock.calls.length, + ); + }); + }); }); diff --git a/apps/backend/src/seed/seed.service.ts b/apps/backend/src/seed/seed.service.ts index 18bec954..cb99931c 100644 --- a/apps/backend/src/seed/seed.service.ts +++ b/apps/backend/src/seed/seed.service.ts @@ -1,22 +1,22 @@ import { Instrument, Note, Song } from '@encode42/nbs.js'; import { faker } from '@faker-js/faker'; +import { Inject, Injectable, Logger } from '@nestjs/common'; + import { - HttpException, - HttpStatus, - Inject, - Injectable, - Logger, -} from '@nestjs/common'; - -import { UPLOAD_CONSTANTS } from '@nbw/config'; -import { + DEFAULT_SEED_DATA_TIME_CAP, + DEFAULT_SEED_FAKER, + SEED_USER_COUNT_MAX, + SEED_USER_COUNT_MIN, + type SeedDevOptions, + UPLOAD_CONSTANTS, +} from '@nbw/config'; +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'; @@ -24,9 +24,6 @@ import { UserService } from '@server/user/user.service'; export class SeedService { public readonly logger = new Logger(SeedService.name); constructor( - @Inject('NODE_ENV') - private readonly NODE_ENV: string, - @Inject(UserService) private readonly userService: UserService, @@ -34,37 +31,47 @@ export class SeedService { private readonly songService: SongService, ) {} - public async seedDev() { - if (this.NODE_ENV !== 'development') { - this.logger.error('Seeding is only allowed in development mode'); - throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); - } + public async seedDev(options: SeedDevOptions = {}) { + const fakerSeed = options.fakerSeed ?? DEFAULT_SEED_FAKER; + const createdAtUpper = options.createdAtUpper ?? DEFAULT_SEED_DATA_TIME_CAP; + const userCount = clampInt( + options.userCount ?? 100, + SEED_USER_COUNT_MIN, + SEED_USER_COUNT_MAX, + ); + + faker.seed(fakerSeed); + this.logger.log( + `Seeding with fakerSeed=${fakerSeed}, userCount=${userCount}, createdAtUpper=${createdAtUpper.toISOString()}`, + ); - const createdUsers = await this.seedUsers(); + const createdUsers = await this.seedUsers(userCount, createdAtUpper); this.logger.log(`Created ${createdUsers.length} users`); - const createdSongs = await this.seedSongs(createdUsers); + const createdSongs = await this.seedSongs(createdUsers, createdAtUpper); this.logger.log(`Created ${createdSongs.length} songs`); } - private async seedUsers() { + private async seedUsers(userCount: number, createdAtUpper: Date) { const createdUsers: UserDocument[] = []; + const createdAtLower = new Date(2020, 0, 1); - for (let i = 0; i < 100; i++) { - const user = await this.userService.createWithEmail( - faker.internet.email(), - ); + for (let i = 0; i < userCount; i++) { + const email = deterministicSeedEmail(i); + const user = await this.userService.createWithEmail(email); //change user creation date (user as any).createdAt = this.generateRandomDate( - new Date(2020, 0, 1), - new Date(), + createdAtLower, + createdAtUpper, ); user.loginCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); user.playCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); user.description = faker.lorem.paragraph(); - user.socialLinks = { + // Plain object only: deleting keys on a Mongoose subdocument can leave `{ _id }` and break + // `findByIdAndUpdate` with "circular reference in the update value". + const socialLinksDraft: Record = { youtube: faker.internet.url(), x: faker.internet.url(), discord: faker.internet.url(), @@ -81,10 +88,10 @@ export class SeedService { telegram: faker.internet.url(), tiktok: faker.internet.url(), }; - - // remove some social links randomly to simulate users not having all of them or having none - for (const key in Object.keys(user.socialLinks)) - if (faker.datatype.boolean()) delete (user.socialLinks as any)[key]; + for (const key of Object.keys(socialLinksDraft)) { + if (faker.datatype.boolean()) delete socialLinksDraft[key]; + } + user.socialLinks = socialLinksDraft as UserDocument['socialLinks']; createdUsers.push(await this.userService.update(user)); } @@ -92,7 +99,7 @@ export class SeedService { return createdUsers; } - private async seedSongs(users: UserDocument[]) { + private async seedSongs(users: UserDocument[], createdAtUpper: Date) { const songs: SongDocument[] = []; const licenses = Object.keys(UPLOAD_CONSTANTS.licenses); const categories = Object.keys(UPLOAD_CONSTANTS.categories); @@ -147,7 +154,7 @@ export class SeedService { //change song creation date (song as any).createdAt = this.generateRandomDate( new Date(2020, 0, 1), - new Date(), + createdAtUpper, ); song.playCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); @@ -333,3 +340,12 @@ export class SeedService { ); } } + +function clampInt(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +/** Stable, unique emails so re-runs with an empty DB always hit the same addresses. */ +function deterministicSeedEmail(index: number): string { + return `nbw-seed-${String(index).padStart(4, '0')}@seed.noteblockworld.test`; +} 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..5acaed34 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; @@ -21,9 +26,26 @@ export class UserService { public async update(user: UserDocument): Promise { try { - return (await this.userModel.findByIdAndUpdate(user._id, user, { - new: true, // return the updated document - })) as UserDocument; + const plain = user.toObject({ flattenMaps: true }) as Record< + string, + unknown + >; + const id = plain._id; + delete plain._id; + delete plain.__v; + if (plain.socialLinks && typeof plain.socialLinks === 'object') { + const sl = { ...(plain.socialLinks as Record) }; + delete sl._id; + plain.socialLinks = sl; + } + + return (await this.userModel.findByIdAndUpdate( + id, + { $set: plain }, + { + new: true, + }, + )) as UserDocument; } catch (error) { if (error instanceof Error) { throw error; @@ -48,54 +70,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 +128,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/e2e/.gitignore b/apps/e2e/.gitignore new file mode 100644 index 00000000..2be0739c --- /dev/null +++ b/apps/e2e/.gitignore @@ -0,0 +1,2 @@ +cypress/downloads/ +cypress/videos/ diff --git a/apps/e2e/cypress.config.ts b/apps/e2e/cypress.config.ts new file mode 100644 index 00000000..96d91229 --- /dev/null +++ b/apps/e2e/cypress.config.ts @@ -0,0 +1,43 @@ +/** + * E2E tests hit the Next app (default http://localhost:3000). + * Start the frontend before `cy:open` / `cy:run`. Many routes also need the API + * (see apps/frontend/.env.local.example: NEXT_PUBLIC_API_URL). + * + * Docker deps (Mongo, MinIO, MailDev): from repo root run `bun run docker:reset` + * before backend/tests when you need a clean stack; use `docker:reset:fresh` to + * wipe volumes as well. + * + * Browser time in specs can align with backend seed caps via `@nbw/config` + * (`DEFAULT_SEED_DATA_TIME_CAP`, `SEED_E2E_BROWSER_CLOCK_MS`). + * + * Authenticated flows: set backend `E2E_AUTH_SECRET` (development) and the same + * value in Cypress (`E2E_AUTH_SECRET` or `CYPRESS_E2E_AUTH_SECRET`), then call + * `cy.sessionViaApi()` (see `cypress/support/commands.ts`). + */ +import { defineConfig } from 'cypress'; + +const baseUrl = + process.env.CYPRESS_BASE_URL?.replace(/\/$/, '') ?? 'http://localhost:3000'; + +export default defineConfig({ + e2e: { + baseUrl, + env: { + API_URL: + process.env.CYPRESS_API_URL ?? + process.env.API_URL ?? + 'http://localhost:4000/v1', + E2E_AUTH_SECRET: + process.env.CYPRESS_E2E_AUTH_SECRET ?? + process.env.E2E_AUTH_SECRET ?? + '', + }, + supportFile: 'cypress/support/e2e.ts', + specPattern: 'cypress/e2e/**/*.cy.ts', + video: false, + // Baseline full-page PNGs live under cypress/baseline/ (tracked). Avoid dumping + // failure screenshots into the same tree as route baselines. + screenshotOnRunFailure: false, + screenshotsFolder: 'cypress/baseline', + }, +}); diff --git a/apps/e2e/cypress/baseline/.gitkeep b/apps/e2e/cypress/baseline/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/e2e/cypress/e2e/smoke.cy.ts b/apps/e2e/cypress/e2e/smoke.cy.ts new file mode 100644 index 00000000..d8ed4a45 --- /dev/null +++ b/apps/e2e/cypress/e2e/smoke.cy.ts @@ -0,0 +1,9 @@ +import { SEED_E2E_BROWSER_CLOCK_MS } from '@nbw/config'; + +describe('smoke', () => { + it('loads the home page', () => { + cy.clock(SEED_E2E_BROWSER_CLOCK_MS, ['Date']); + cy.visit('/'); + cy.get('body').should('be.visible'); + }); +}); diff --git a/apps/e2e/cypress/e2e/snapshots/pages.cy.ts b/apps/e2e/cypress/e2e/snapshots/pages.cy.ts new file mode 100644 index 00000000..ddbf7185 --- /dev/null +++ b/apps/e2e/cypress/e2e/snapshots/pages.cy.ts @@ -0,0 +1,77 @@ +/** + * Full-page screenshots for public routes (committed under cypress/baseline/). + * + * Prerequisites: frontend (and usually API) running; regenerate after UI changes: + * cd apps/e2e && bun run cy:baseline + * + * Dynamic routes (/song/:id, /blog/:id, …) use CYPRESS_* env vars when set; otherwise skipped. + */ +import { SEED_E2E_BROWSER_CLOCK_MS } from '@nbw/config'; + +const VIEWPORT = { width: 1280, height: 720 } as const; + +type PageTarget = { path: string; file: string }; + +const STATIC_PAGES: PageTarget[] = [ + { path: '/', file: 'page-home' }, + { path: '/about', file: 'page-about' }, + { path: '/contact', file: 'page-contact' }, + { path: '/search', file: 'page-search' }, + { path: '/upload', file: 'page-upload' }, + { path: '/my-songs', file: 'page-my-songs' }, + { path: '/help', file: 'page-help' }, + { path: '/blog', file: 'page-blog' }, + { path: '/login', file: 'page-login' }, + { path: '/login/email', file: 'page-login-email' }, + { path: '/logout', file: 'page-logout' }, + { path: '/privacy', file: 'page-privacy' }, + { path: '/terms', file: 'page-terms' }, + { path: '/guidelines', file: 'page-guidelines' }, + { + path: '/__cypress_unknown_route__/nbw', + file: 'page-not-found', + }, +]; + +function optionalPage( + envName: string, + pathSuffix: string, + file: string, +): PageTarget | null { + const id = Cypress.env(envName) as string | undefined; + if (!id || typeof id !== 'string') { + return null; + } + return { path: `${pathSuffix}/${id}`, file }; +} + +describe('Page baseline snapshots', () => { + beforeEach(() => { + cy.viewport(VIEWPORT.width, VIEWPORT.height); + cy.clock(SEED_E2E_BROWSER_CLOCK_MS, ['Date']); + }); + + for (const { path, file } of STATIC_PAGES) { + it(file, () => { + cy.visit(path, { failOnStatusCode: false }); + cy.get('body', { timeout: 45_000 }).should('be.visible'); + cy.wait(800); + cy.screenshot(file, { capture: 'fullPage', overwrite: true }); + }); + } + + const dynamicPages: PageTarget[] = [ + optionalPage('SNAPSHOT_SONG_ID', '/song', 'page-song-detail'), + optionalPage('SNAPSHOT_BLOG_ID', '/blog', 'page-blog-detail'), + optionalPage('SNAPSHOT_HELP_ID', '/help', 'page-help-detail'), + ].filter((p): p is PageTarget => p !== null); + + for (const { path, file } of dynamicPages) { + it(file, () => { + cy.visit(path, { failOnStatusCode: false }); + cy.get('body', { timeout: 45_000 }).should('be.visible'); + cy.wait(800); + cy.screenshot(file, { capture: 'fullPage', overwrite: true }); + }); + } +}); diff --git a/apps/e2e/cypress/support/commands.ts b/apps/e2e/cypress/support/commands.ts new file mode 100644 index 00000000..1d0d90bd --- /dev/null +++ b/apps/e2e/cypress/support/commands.ts @@ -0,0 +1,49 @@ +import { DEFAULT_E2E_SEED_USER_EMAIL, E2E_AUTH_HEADER } from '@nbw/config'; + +Cypress.Commands.add( + 'sessionViaApi', + (overrides?: { email?: string; userId?: string }) => { + const apiRoot = (Cypress.env('API_URL') as string | undefined)?.replace( + /\/$/, + '', + ); + if (!apiRoot) { + throw new Error( + 'Cypress env API_URL is missing (e.g. http://localhost:4000/v1)', + ); + } + const secret = Cypress.env('E2E_AUTH_SECRET') as string | undefined; + if (!secret) { + throw new Error( + 'Cypress env E2E_AUTH_SECRET is required for sessionViaApi (must match backend E2E_AUTH_SECRET)', + ); + } + + const email = overrides?.email?.trim(); + const userId = overrides?.userId?.trim(); + if (email && userId) { + throw new Error('sessionViaApi: pass at most one of email or userId'); + } + + const body = email + ? { email } + : userId + ? { userId } + : { email: DEFAULT_E2E_SEED_USER_EMAIL }; + + cy.request({ + method: 'POST', + url: `${apiRoot}/auth/e2e/session`, + headers: { [E2E_AUTH_HEADER]: secret }, + body, + failOnStatusCode: true, + }).then((res) => { + const { access_token, refresh_token } = res.body as { + access_token: string; + refresh_token: string; + }; + cy.setCookie('token', access_token, { path: '/' }); + cy.setCookie('refresh_token', refresh_token, { path: '/' }); + }); + }, +); diff --git a/apps/e2e/cypress/support/e2e.ts b/apps/e2e/cypress/support/e2e.ts new file mode 100644 index 00000000..1221b17e --- /dev/null +++ b/apps/e2e/cypress/support/e2e.ts @@ -0,0 +1 @@ +import './commands'; diff --git a/apps/e2e/cypress/support/index.d.ts b/apps/e2e/cypress/support/index.d.ts new file mode 100644 index 00000000..0cdfe58a --- /dev/null +++ b/apps/e2e/cypress/support/index.d.ts @@ -0,0 +1,18 @@ +export {}; + +declare global { + namespace Cypress { + interface Chainable { + /** + * `POST /v1/auth/e2e/session` then sets `token` and `refresh_token` cookies + * on the spec origin (`baseUrl`). Requires backend `NODE_ENV=development`, + * non-empty `E2E_AUTH_SECRET`, and Cypress env `E2E_AUTH_SECRET` + `API_URL`. + * Defaults to the first seeded user email when no overrides are passed. + */ + sessionViaApi(overrides?: { + email?: string; + userId?: string; + }): Chainable; + } + } +} diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 00000000..e0ac4281 --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,23 @@ +{ + "name": "@nbw/e2e", + "version": "0.1.0", + "private": true, + "description": "Cypress end-to-end tests for NoteBlockWorld", + "license": "ISC", + "scripts": { + "postinstall": "cypress install", + "build": "exit 0", + "start": "exit 0", + "dev": "exit 0", + "cy:open": "cypress open", + "cy:run": "cypress run", + "cy:baseline": "cypress run --spec cypress/e2e/snapshots/pages.cy.ts" + }, + "dependencies": { + "@nbw/config": "workspace:*" + }, + "devDependencies": { + "cypress": "^14.3.0", + "typescript": "^5.9.2" + } +} diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 00000000..260328ea --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "types": ["cypress", "node"] + }, + "include": ["cypress/**/*.ts", "cypress.config.ts"] +} 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..3aaffaf5 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", @@ -104,6 +105,17 @@ "typescript": "^5.9.3", }, }, + "apps/e2e": { + "name": "@nbw/e2e", + "version": "0.1.0", + "dependencies": { + "@nbw/config": "workspace:*", + }, + "devDependencies": { + "cypress": "^14.3.0", + "typescript": "^5.9.2", + }, + }, "apps/frontend": { "name": "@nbw/frontend", "version": "0.1.0", @@ -116,7 +128,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 +198,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 +257,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", @@ -444,6 +471,10 @@ "@css-inline/css-inline-win32-x64-msvc": ["@css-inline/css-inline-win32-x64-msvc@0.14.1", "", { "os": "win32", "cpu": "x64" }, "sha512-nzotGiaiuiQW78EzsiwsHZXbxEt6DiMUFcDJ6dhiliomXxnlaPyBfZb6/FMBgRJOf6sknDt/5695OttNmbMYzg=="], + "@cypress/request": ["@cypress/request@3.0.10", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~4.0.4", "http-signature": "~1.4.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", "qs": "~6.14.1", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" } }, "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ=="], + + "@cypress/xvfb": ["@cypress/xvfb@1.2.4", "", { "dependencies": { "debug": "^3.1.0", "lodash.once": "^4.1.1" } }, "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q=="], + "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -684,6 +715,8 @@ "@nbw/database": ["@nbw/database@workspace:packages/database"], + "@nbw/e2e": ["@nbw/e2e@workspace:apps/e2e"], + "@nbw/frontend": ["@nbw/frontend@workspace:apps/frontend"], "@nbw/song": ["@nbw/song@workspace:packages/song"], @@ -692,6 +725,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=="], @@ -1120,6 +1155,10 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/sinonjs__fake-timers": ["@types/sinonjs__fake-timers@8.1.1", "", {}, "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g=="], + + "@types/sizzle": ["@types/sizzle@2.3.10", "", {}, "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], @@ -1144,6 +1183,8 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="], @@ -1252,6 +1293,8 @@ "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -1274,6 +1317,8 @@ "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], + "arch": ["arch@2.2.0", "", {}, "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="], + "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -1302,10 +1347,16 @@ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "assert-never": ["assert-never@1.4.0", "", {}, "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -1314,12 +1365,18 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], "autoprefixer": ["autoprefixer@10.4.22", "", { "dependencies": { "browserslist": "^4.27.0", "caniuse-lite": "^1.0.30001754", "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws-sign2": ["aws-sign2@0.7.0", "", {}, "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA=="], + + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="], "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], @@ -1348,12 +1405,18 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA=="], + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "blob-util": ["blob-util@2.0.2", "", {}, "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ=="], + + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -1374,6 +1437,8 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1384,6 +1449,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cachedir": ["cachedir@2.4.0", "", {}, "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -1398,6 +1465,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001759", "", {}, "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw=="], + "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -1416,6 +1485,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "check-more-types": ["check-more-types@2.24.0", "", {}, "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA=="], + "cheerio": ["cheerio@1.0.0-rc.12", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "htmlparser2": "^8.0.1", "parse5": "^7.0.0", "parse5-htmlparser2-tree-adapter": "^7.0.0" } }, "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -1436,6 +1507,8 @@ "clean-css": ["clean-css@4.2.4", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A=="], + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -1468,6 +1541,8 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "colors": ["colors@1.4.0", "", {}, "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -1476,6 +1551,8 @@ "comment-json": ["comment-json@4.4.1", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg=="], + "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -1520,14 +1597,20 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cypress": ["cypress@14.5.4", "", { "dependencies": { "@cypress/request": "^3.0.9", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", "ci-info": "^4.1.0", "cli-cursor": "^3.1.0", "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", "extract-zip": "2.0.1", "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", "hasha": "5.2.2", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", "minimist": "^1.2.8", "ospath": "^1.2.2", "pretty-bytes": "^5.6.0", "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", "semver": "^7.7.1", "supports-color": "^8.1.1", "tmp": "~0.2.3", "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, "bin": { "cypress": "bin/cypress" } }, "sha512-0Dhm4qc9VatOcI1GiFGVt8osgpPdqJLHzRwcAB5MSD/CAAts3oybvPUPawHyvJZUd8osADqZe/xzMsZ8sDTjXw=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -1594,6 +1677,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "editorconfig": ["editorconfig@1.0.4", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q=="], @@ -1618,8 +1703,12 @@ "encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -1724,11 +1813,15 @@ "event-stream": ["event-stream@3.3.4", "", { "dependencies": { "duplexer": "~0.1.1", "from": "~0", "map-stream": "~0.1.0", "pause-stream": "0.0.11", "split": "0.3", "stream-combiner": "~0.0.4", "through": "~2.3.1" } }, "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g=="], + "eventemitter2": ["eventemitter2@6.4.7", "", {}, "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA=="], + + "executable": ["executable@4.1.1", "", { "dependencies": { "pify": "^2.2.0" } }, "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg=="], "exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="], @@ -1744,6 +1837,10 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -1762,10 +1859,14 @@ "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], @@ -1792,6 +1893,8 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "forever-agent": ["forever-agent@0.6.1", "", {}, "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="], + "fork-ts-checker-webpack-plugin": ["fork-ts-checker-webpack-plugin@9.1.0", "", { "dependencies": { "@babel/code-frame": "^7.16.7", "chalk": "^4.1.2", "chokidar": "^4.0.1", "cosmiconfig": "^8.2.0", "deepmerge": "^4.2.2", "fs-extra": "^10.0.0", "memfs": "^3.4.1", "minimatch": "^3.0.4", "node-abort-controller": "^3.0.1", "schema-utils": "^3.1.1", "semver": "^7.3.5", "tapable": "^2.2.1" }, "peerDependencies": { "typescript": ">3.6.0", "webpack": "^5.11.0" } }, "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -1806,7 +1909,7 @@ "from": ["from@0.1.7", "", {}, "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="], - "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="], @@ -1838,18 +1941,24 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "getos": ["getos@3.2.1", "", { "dependencies": { "async": "^3.2.0" } }, "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q=="], + + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-dirs": ["global-dirs@3.0.1", "", { "dependencies": { "ini": "2.0.0" } }, "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA=="], + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -1878,6 +1987,8 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasha": ["hasha@5.2.2", "", { "dependencies": { "is-stream": "^2.0.0", "type-fest": "^0.8.0" } }, "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], @@ -1906,7 +2017,9 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "http-signature": ["http-signature@1.4.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", "sshpk": "^1.18.0" } }, "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg=="], + + "human-signals": ["human-signals@1.1.1", "", {}, "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], @@ -1928,11 +2041,13 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1992,6 +2107,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-installed-globally": ["is-installed-globally@0.4.0", "", { "dependencies": { "global-dirs": "^3.0.0", "is-path-inside": "^3.0.2" } }, "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ=="], + "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], @@ -2002,6 +2119,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -2020,6 +2139,8 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], @@ -2034,6 +2155,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], @@ -2118,16 +2241,22 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -2136,6 +2265,8 @@ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + "jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="], + "jstransformer": ["jstransformer@1.0.0", "", { "dependencies": { "is-promise": "^2.0.0", "promise": "^7.0.1" } }, "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -2160,6 +2291,8 @@ "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "lazy-ass": ["lazy-ass@1.6.0", "", {}, "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw=="], + "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -2472,6 +2605,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=="], @@ -2552,6 +2687,8 @@ "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + "ospath": ["ospath@1.2.2", "", {}, "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "p-event": ["p-event@4.2.0", "", { "dependencies": { "p-timeout": "^3.1.0" } }, "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ=="], @@ -2562,6 +2699,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], @@ -2624,12 +2763,18 @@ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], @@ -2646,12 +2791,16 @@ "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], + "pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], "preview-email": ["preview-email@3.1.0", "", { "dependencies": { "ci-info": "^3.8.0", "display-notification": "2.0.0", "fixpack": "^4.0.0", "get-port": "5.1.1", "mailparser": "^3.7.1", "nodemailer": "^6.9.13", "open": "7", "p-event": "4.2.0", "p-wait-for": "3.2.0", "pug": "^3.0.3", "uuid": "^9.0.1" } }, "sha512-ZtV1YrwscEjlrUzYrTSs6Nwo49JM3pXLM4fFOBSC3wSni+bxaWlw9/Qgk75PZO8M7cX2EybmL2iwvaV3vkAttw=="], "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], @@ -2698,6 +2847,8 @@ "pug-walk": ["pug-walk@2.0.0", "", {}, "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], @@ -2774,6 +2925,8 @@ "remove-markdown": ["remove-markdown@0.6.3", "", {}, "sha512-Qvp2p0Q1irE7AaJO7QemJe04HdObHylJrG+q4hszvPlYp7q4EvfINpEIaIEFdB+3XTDp1h6fiyT60ae00gmRow=="], + "request-progress": ["request-progress@3.0.0", "", { "dependencies": { "throttleit": "^1.0.0" } }, "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -2808,7 +2961,7 @@ "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -2898,6 +3051,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], @@ -2992,12 +3147,20 @@ "throttle-debounce": ["throttle-debounce@2.3.0", "", {}, "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ=="], + "throttleit": ["throttleit@1.0.1", "", {}, "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ=="], + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3008,6 +3171,8 @@ "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -3032,6 +3197,10 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -3094,6 +3263,8 @@ "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + "untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="], "upper-case": ["upper-case@1.1.3", "", {}, "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA=="], @@ -3128,6 +3299,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -3196,6 +3369,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -3244,6 +3419,14 @@ "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@cypress/request/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "@cypress/request/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + + "@cypress/request/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "@cypress/xvfb/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -3312,24 +3495,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.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - "@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=="], @@ -3418,6 +3603,8 @@ "@types/serve-static/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/yauzl/@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -3446,6 +3633,8 @@ "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "create-jest/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], @@ -3454,7 +3643,15 @@ "create-jest/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "cypress/cli-table3": ["cli-table3@0.6.1", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "colors": "1.4.0" } }, "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA=="], + + "cypress/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], + + "cypress/listr2": ["listr2@3.14.0", "", { "dependencies": { "cli-truncate": "^2.1.0", "colorette": "^2.0.16", "log-update": "^4.0.0", "p-map": "^4.0.0", "rfdc": "^1.3.0", "rxjs": "^7.5.1", "through": "^2.3.8", "wrap-ansi": "^7.0.0" }, "peerDependencies": { "enquirer": ">= 2.3.0 < 3" }, "optionalPeers": ["enquirer"] }, "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g=="], + + "cypress/proxy-from-env": ["proxy-from-env@1.0.0", "", {}, "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A=="], + + "cypress/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -3484,12 +3681,16 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "fixpack/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "fork-ts-checker-webpack-plugin/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "fork-ts-checker-webpack-plugin/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -3498,6 +3699,8 @@ "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], + "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "html-minifier/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -3514,6 +3717,8 @@ "istanbul-lib-source-maps/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "jest-changed-files/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "jest-circus/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "jest-config/babel-jest": ["babel-jest@30.2.0", "", { "dependencies": { "@jest/transform": "30.2.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw=="], @@ -3558,10 +3763,6 @@ "juice/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], - "jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -3942,12 +4143,14 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "randombytes/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "read-package-json-fast/json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="], + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "resolve-cwd/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "run-applescript/execa": ["execa@0.10.0", "", { "dependencies": { "cross-spawn": "^6.0.0", "get-stream": "^3.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } }, "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw=="], @@ -3968,6 +4171,8 @@ "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser-webpack-plugin/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -3992,6 +4197,8 @@ "v8-to-istanbul/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "vfile-reporter/string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], "vfile-reporter/supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], @@ -4032,6 +4239,8 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@cypress/request/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -4074,21 +4283,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.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "@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 +4305,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=="], @@ -4164,6 +4375,8 @@ "@types/serve-static/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -4200,6 +4413,12 @@ "create-jest/jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "cypress/listr2/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + + "cypress/listr2/log-update": ["log-update@4.0.0", "", { "dependencies": { "ansi-escapes": "^4.3.0", "cli-cursor": "^3.1.0", "slice-ansi": "^4.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg=="], + + "cypress/listr2/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], @@ -4214,6 +4433,10 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "jest-changed-files/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "jest-changed-files/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "jest-circus/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "jest-config/babel-jest/@jest/transform": ["@jest/transform@30.2.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.2.0", "jest-regex-util": "30.0.1", "jest-util": "30.2.0", "micromatch": "^4.0.8", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA=="], @@ -4436,6 +4659,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=="], @@ -4516,6 +4741,12 @@ "create-jest/jest-config/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "cypress/listr2/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + + "cypress/listr2/log-update/slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + + "cypress/listr2/log-update/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "jest-config/babel-jest/@jest/transform/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "jest-config/babel-jest/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], @@ -4558,12 +4789,8 @@ "passport-jwt/jsonwebtoken/jws/jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="], - "passport-jwt/jsonwebtoken/jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "passport-magic-login/jsonwebtoken/jws/jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="], - "passport-magic-login/jsonwebtoken/jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "run-applescript/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], @@ -4610,6 +4837,8 @@ "@nbw/thumbnail/jest/@jest/core/@jest/reporters/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + "@nbw/thumbnail/jest/@jest/core/jest-changed-files/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@nbw/thumbnail/jest/@jest/core/jest-config/@jest/test-sequencer": ["@jest/test-sequencer@29.7.0", "", { "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "slash": "^3.0.0" } }, "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw=="], "@nbw/thumbnail/jest/@jest/core/jest-config/glob": ["glob@7.1.7", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ=="], @@ -4696,6 +4925,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=="], @@ -4770,6 +5001,10 @@ "run-applescript/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], + "@nbw/thumbnail/jest/@jest/core/jest-changed-files/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@nbw/thumbnail/jest/@jest/core/jest-changed-files/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "@nbw/thumbnail/jest/@jest/core/jest-config/jest-circus/@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], "@nbw/thumbnail/jest/@jest/core/jest-config/jest-circus/@jest/expect": ["@jest/expect@29.7.0", "", { "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" } }, "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ=="], diff --git a/docker-compose.yml b/docker-compose.yml index 2161a172..a5d8990f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,32 @@ services: - MONGO_INITDB_ROOT_PASSWORD=noteblockworldpassword - MONGO_INITDB_DATABASE=noteblockworld - MONGO_INITDB_ROOT_USERNAME=noteblockworlduser + healthcheck: + test: + [ + 'CMD', + 'mongosh', + '-u', + 'noteblockworlduser', + '-p', + 'noteblockworldpassword', + '--authenticationDatabase', + 'admin', + '--eval', + "db.adminCommand('ping').ok", + ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 15s maildev: container_name: noteblockworld-maildev-dev image: maildev/maildev + # Image HEALTHCHECK can report unhealthy and block `compose up --wait`, so + # `docker:minio-init` never runs and MinIO buckets are never created. + healthcheck: + disable: true ports: - '1080:1080' # Web Interface - '1025:1025' # SMTP Server @@ -35,15 +57,25 @@ services: - MINIO_ROOT_PASSWORD=minioadmin volumes: - minio_data:/data + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s # You can access the MinIO web interface at http://localhost:9000 # You can access the MinIO console at http://localhost:9001 + # One-shot: exits 0 after buckets/CORS. Not part of `up --wait` (that waits for long-running services). mc: container_name: minio-client + profiles: + - minio-init image: minio/mc entrypoint: ['/bin/sh', '-c'] depends_on: - - minio + minio: + condition: service_healthy environment: - MINIO_ROOT_USER=minioadmin - MINIO_ROOT_PASSWORD=minioadmin diff --git a/package.json b/package.json index 3f66ddb0..3136a124 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,13 @@ "build:web": "bun run --filter '@nbw/frontend' build", "build:all": "bun run build:packages && bun run build:apps", "docker": "docker-compose -f docker-compose-dev.yml up -d && bun run dev && docker-compose down", + "docker:minio-init": "docker compose --profile minio-init run --rm mc", + "docker:up": "docker compose up -d --wait && bun run docker:minio-init", + "docker:down": "docker compose down", + "docker:reset": "docker compose down && docker compose up -d --wait && bun run docker:minio-init", + "docker:reset:fresh": "docker compose down -v && docker compose up -d --wait && bun run docker:minio-init", + "test:cy:with-docker": "bun run docker:reset && bun run test:cy", + "test:cy:with-docker:fresh": "bun run docker:reset:fresh && bun run test:cy", "start:apps": "bun run --filter './apps/*' start", "start:server": "bun run --filter '@nbw/backend' start", "start:web": "bun run --filter '@nbw/frontend' start", @@ -58,8 +65,9 @@ "lint": "eslint \"**/*.{ts,tsx}\" --fix", "lint:check": "eslint \"**/*.{ts,tsx}\"", "format": "prettier --write .", - "cy:open": "bun run test:cy", - "test:cy": "bun run --filter 'tests' cy:open", + "cy:open": "cd apps/e2e && bun run cy:open", + "test:cy": "cd apps/e2e && bun run cy:run", + "cy:baseline": "cd apps/e2e && bun run cy:baseline", "prepare": "husky", "lint-staged": "lint-staged" }, diff --git a/packages/configs/src/e2e.ts b/packages/configs/src/e2e.ts new file mode 100644 index 00000000..56fde100 --- /dev/null +++ b/packages/configs/src/e2e.ts @@ -0,0 +1,9 @@ +/** + * Request header for `POST /v1/auth/e2e/session` (Nest, development only). + * Value must match backend env `E2E_AUTH_SECRET`. + */ +export const E2E_AUTH_HEADER = 'x-nbw-e2e-auth'; + +/** First deterministic seed user (`deterministicSeedEmail(0)` in seed service). */ +export const DEFAULT_E2E_SEED_USER_EMAIL = + 'nbw-seed-0000@seed.noteblockworld.test'; diff --git a/packages/configs/src/index.ts b/packages/configs/src/index.ts index debb0308..ae4b0801 100644 --- a/packages/configs/src/index.ts +++ b/packages/configs/src/index.ts @@ -1,3 +1,5 @@ export * from './colors'; +export * from './e2e'; +export * from './seed'; export * from './song'; export * from './user'; diff --git a/packages/configs/src/seed.ts b/packages/configs/src/seed.ts new file mode 100644 index 00000000..2087aae7 --- /dev/null +++ b/packages/configs/src/seed.ts @@ -0,0 +1,28 @@ +/** Default Faker PRNG seed: same empty DB + same seed produces the same dataset. */ +export const DEFAULT_SEED_FAKER = 42_424_242; + +/** + * Upper bound for random `createdAt` values so seed runs are reproducible + * (avoid `new Date()` which moves every run). Shared with Cypress for clock alignment. + */ +export const DEFAULT_SEED_DATA_TIME_CAP = new Date('2025-06-15T12:00:00.000Z'); + +export const SEED_USER_COUNT_MIN = 1; +export const SEED_USER_COUNT_MAX = 500; + +/** + * Same empty DB + same options ⇒ same Faker-driven fields, NBS payloads, and timestamps + * (emails are stable `nbw-seed-NNNN@…`). Mongo `_id` and song `publicId` (nanoid) still vary per run. + */ +export type SeedDevOptions = { + /** Faker PRNG seed (default {@link DEFAULT_SEED_FAKER}). */ + fakerSeed?: number; + /** Inclusive upper bound for random `createdAt` on users and songs. */ + createdAtUpper?: Date; + /** How many users to create (clamped to 1–500, default 100). */ + userCount?: number; +}; + +/** Milliseconds for `cy.clock`: one day after {@link DEFAULT_SEED_DATA_TIME_CAP}. */ +export const SEED_E2E_BROWSER_CLOCK_MS = + DEFAULT_SEED_DATA_TIME_CAP.getTime() + 24 * 60 * 60 * 1000; 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" } ] }