diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 800af86..3909406 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,5 +1,18 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Req, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { Throttle } from '../../common/decorators/throttle.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -19,6 +32,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto'; import { GoogleAuthGuard } from './guards/google-auth.guard'; import { RegisterDto } from './dto/register.dto'; import { SendEmailVerificationDto } from './dto/send-email-verification.dto'; +import { SignupCompleteDto } from './dto/signup-complete.dto'; +import { SignupStartDto } from './dto/signup-start.dto'; +import { SignupVerifyDto } from './dto/signup-verify.dto'; import { SuperAdminLoginDto } from './dto/super-admin-login.dto'; import { VerifyEmailDto } from './dto/verify-email.dto'; import { VerifyResetCodeDto } from './dto/verify-reset-code.dto'; @@ -41,6 +57,62 @@ export class AuthController { return this.authService.registerBasic(dto); } + @Post('signup/start') + @Throttle(10, 60_000) + async signupStart(@Body() dto: SignupStartDto) { + return this.authService.signupStart(dto); + } + + @HttpCode(HttpStatus.OK) + @Post('signup/verify') + @Throttle(20, 60_000) + async signupVerify(@Body() dto: SignupVerifyDto) { + return this.authService.signupVerify(dto); + } + + @HttpCode(HttpStatus.OK) + @Post('signup/complete') + @Throttle(10, 60_000) + @UseInterceptors(FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }])) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + signupToken: { type: 'string' }, + musicRoles: { type: 'array', items: { type: 'string' } }, + musicGenres: { type: 'array', items: { type: 'string' } }, + experienceLevel: { type: 'string' }, + favoriteInstruments: { type: 'array', items: { type: 'string' } }, + favoriteMaqamat: { type: 'array', items: { type: 'string' } }, + stageName: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + bio: { type: 'string' }, + location: { type: 'string' }, + latitude: { type: 'number' }, + longitude: { type: 'number' }, + isPrivate: { type: 'boolean' }, + avatarFile: { type: 'string', format: 'binary' }, + }, + required: ['signupToken', 'name'], + }, + }) + async signupComplete( + @Body() dto: SignupCompleteDto, + @UploadedFiles() + files?: { + avatarFile?: Array<{ + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; + }>; + }, + ) { + return this.authService.signupComplete(dto, files?.avatarFile?.[0]); + } + @HttpCode(HttpStatus.OK) @Post('login') @Throttle(20, 60_000) diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 3c724a9..0ee3100 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -17,6 +17,7 @@ import { PasswordResetCodeSchema, } from './schemas/password-reset-code.schema'; import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema'; +import { SignupDraft, SignupDraftSchema } from './schemas/signup-draft.schema'; import { SuperAdminRefreshToken, SuperAdminRefreshTokenSchema, @@ -54,6 +55,10 @@ import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy'; name: EmailVerificationCode.name, schema: EmailVerificationCodeSchema, }, + { + name: SignupDraft.name, + schema: SignupDraftSchema, + }, ]), EmailModule, UsersModule, diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index 723cb9e..8b3265b 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -10,6 +10,7 @@ import { PasswordResetCodeDocument, } from './schemas/password-reset-code.schema'; import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema'; +import { SignupDraft, SignupDraftDocument } from './schemas/signup-draft.schema'; import { SuperAdminRefreshToken, SuperAdminRefreshTokenDocument, @@ -26,6 +27,8 @@ export class AuthRepository { private readonly passwordResetCodeModel: Model, @InjectModel(EmailVerificationCode.name) private readonly emailVerificationCodeModel: Model, + @InjectModel(SignupDraft.name) + private readonly signupDraftModel: Model, ) {} async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise { @@ -280,4 +283,81 @@ export class AuthRepository { .updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true }) .exec(); } + + async upsertSignupDraft(params: { + email: string; + passwordHash: string; + codeHash: string; + codeExpiresAt: Date; + expiresAt: Date; + }): Promise { + return this.signupDraftModel + .findOneAndUpdate( + { + email: params.email.toLowerCase(), + consumed: false, + expiresAt: { $gt: new Date() }, + }, + { + $set: { + email: params.email.toLowerCase(), + passwordHash: params.passwordHash, + codeHash: params.codeHash, + codeExpiresAt: params.codeExpiresAt, + expiresAt: params.expiresAt, + verifiedAt: null, + signupTokenHash: '', + signupTokenExpiresAt: null, + attempts: 0, + consumed: false, + }, + }, + { new: true, upsert: true, setDefaultsOnInsert: true }, + ) + .select('+passwordHash +codeHash +signupTokenHash') + .exec(); + } + + async findSignupDraftWithSecrets(id: string): Promise { + if (!Types.ObjectId.isValid(id)) { + return null; + } + + return this.signupDraftModel + .findById(id) + .select('+passwordHash +codeHash +signupTokenHash') + .exec(); + } + + async findSignupDraftByTokenHash(signupTokenHash: string): Promise { + return this.signupDraftModel + .findOne({ + signupTokenHash, + consumed: false, + }) + .select('+passwordHash +codeHash +signupTokenHash') + .exec(); + } + + async incrementSignupDraftAttempts(id: string): Promise { + await this.signupDraftModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec(); + } + + async markSignupDraftVerified( + id: string, + signupTokenHash: string, + signupTokenExpiresAt: Date, + ): Promise { + await this.signupDraftModel + .findByIdAndUpdate(id, { + verifiedAt: new Date(), + signupTokenHash, + signupTokenExpiresAt, + }) + .exec(); + } + + async markSignupDraftConsumed(id: string): Promise { + await this.signupDraftModel.findByIdAndUpdate(id, { consumed: true }).exec(); + } } diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 75072df..2376d5c 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -1,4 +1,4 @@ -import { UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; import { validate } from 'class-validator'; @@ -185,3 +185,269 @@ describe('AuthService Google token login', () => { expect(authServiceSource).not.toContain('admin.auth().verifyIdToken'); }); }); + +describe('AuthService deferred signup flow', () => { + const createDeferredSignupService = (overrides?: { + usersService?: Record; + authRepository?: Record; + jwtService?: Record; + }) => { + const usersService = { + findByEmail: jest.fn().mockResolvedValue(null), + findByUsername: jest.fn().mockResolvedValue(null), + createWithOptionalAvatarFile: jest.fn(), + findByIdOrFail: jest.fn(), + rollbackSignupCreatedUser: jest.fn(), + ...(overrides?.usersService ?? {}), + }; + const authRepository = { + upsertSignupDraft: jest.fn().mockResolvedValue({ id: 'draft-1' }), + findSignupDraftWithSecrets: jest.fn(), + incrementSignupDraftAttempts: jest.fn().mockResolvedValue(undefined), + markSignupDraftVerified: jest.fn().mockResolvedValue(undefined), + findSignupDraftByTokenHash: jest.fn(), + markSignupDraftConsumed: jest.fn().mockResolvedValue(undefined), + createRefreshToken: jest.fn().mockResolvedValue(undefined), + ...(overrides?.authRepository ?? {}), + }; + const jwtService = { + signAsync: jest.fn().mockResolvedValueOnce('access-token').mockResolvedValueOnce('refresh-token'), + ...(overrides?.jwtService ?? {}), + }; + const configService = { + get: jest.fn((key: string) => { + const values: Record = { + nodeEnv: 'test', + 'security.bcryptSaltRounds': 4, + 'emailVerification.maxAttempts': 5, + 'jwt.accessSecret': 'access-secret', + 'jwt.accessExpiresIn': '15m', + 'jwt.refreshSecret': 'refresh-secret', + 'jwt.refreshExpiresIn': '30d', + 'security.refreshTokenHashSecret': 'refresh-hash-secret', + }; + return values[key]; + }), + }; + const emailService = { + sendVerificationCode: jest.fn().mockResolvedValue(undefined), + }; + + const service = new AuthService( + usersService as any, + authRepository as any, + jwtService as any, + configService as any, + emailService as any, + ); + + return { service, usersService, authRepository, jwtService, emailService }; + }; + + it('signup/start sends an OTP and does not create a user', async () => { + const { service, usersService, authRepository, emailService } = createDeferredSignupService(); + + const result = await service.signupStart({ + email: 'New.User@Example.com', + password: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }); + + expect(result).toMatchObject({ + message: 'Verification code sent', + email: 'new.user@example.com', + signupId: 'draft-1', + }); + expect(result.debugCode).toMatch(/^\d{6}$/); + expect(usersService.findByEmail).toHaveBeenCalledWith('new.user@example.com'); + expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled(); + expect(authRepository.upsertSignupDraft).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new.user@example.com' }), + ); + expect(emailService.sendVerificationCode).toHaveBeenCalledWith( + 'new.user@example.com', + expect.stringMatching(/^\d{6}$/), + 20, + ); + }); + + it('signup/start fails if the email is already used', async () => { + const { service, authRepository } = createDeferredSignupService({ + usersService: { findByEmail: jest.fn().mockResolvedValue({ id: 'existing-user' }) }, + }); + + await expect( + service.signupStart({ + email: 'used@example.com', + password: 'StrongPass123!', + confirmPassword: 'StrongPass123!', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(authRepository.upsertSignupDraft).not.toHaveBeenCalled(); + }); + + it('signup/start fails if password confirmation does not match', async () => { + const { service, authRepository } = createDeferredSignupService(); + + await expect( + service.signupStart({ + email: 'new@example.com', + password: 'StrongPass123!', + confirmPassword: 'DifferentPass123!', + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(authRepository.upsertSignupDraft).not.toHaveBeenCalled(); + }); + + it('signup/verify returns a signup token without auth tokens', async () => { + const codeHash = await hashValue('123456', 4); + const { service, authRepository, usersService } = createDeferredSignupService(); + authRepository.findSignupDraftWithSecrets.mockResolvedValue({ + id: 'draft-1', + email: 'new@example.com', + codeHash, + codeExpiresAt: new Date(Date.now() + 60_000), + expiresAt: new Date(Date.now() + 60_000), + attempts: 0, + consumed: false, + }); + + const result = await service.signupVerify({ + signupId: 'draft-1', + email: 'new@example.com', + code: '123456', + }); + + expect(result.message).toBe('Email verified'); + expect(result.signupToken).toEqual(expect.any(String)); + expect((result as any).accessToken).toBeUndefined(); + expect((result as any).refreshToken).toBeUndefined(); + expect(authRepository.markSignupDraftVerified).toHaveBeenCalledWith( + 'draft-1', + expect.stringMatching(/^sha256:/), + expect.any(Date), + ); + expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled(); + }); + + it('signup/verify fails with a wrong code', async () => { + const codeHash = await hashValue('123456', 4); + const { service, authRepository } = createDeferredSignupService(); + authRepository.findSignupDraftWithSecrets.mockResolvedValue({ + id: 'draft-1', + email: 'new@example.com', + codeHash, + codeExpiresAt: new Date(Date.now() + 60_000), + expiresAt: new Date(Date.now() + 60_000), + attempts: 0, + consumed: false, + }); + + await expect( + service.signupVerify({ + signupId: 'draft-1', + email: 'new@example.com', + code: '654321', + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(authRepository.incrementSignupDraftAttempts).toHaveBeenCalledWith('draft-1'); + }); + + it('signup/complete creates the user and returns auth tokens', async () => { + const createdUser = { + id: 'user-1', + username: 'new_user', + role: 'user', + avatar: '', + }; + const safeUser = { + toObject: () => ({ id: 'user-1', email: 'new@example.com', username: 'new_user' }), + }; + const { service, usersService, authRepository } = createDeferredSignupService(); + authRepository.findSignupDraftByTokenHash.mockResolvedValue({ + id: 'draft-1', + email: 'new@example.com', + passwordHash: 'hashed-password', + verifiedAt: new Date(), + signupTokenExpiresAt: new Date(Date.now() + 60_000), + expiresAt: new Date(Date.now() + 60_000), + consumed: false, + }); + usersService.createWithOptionalAvatarFile.mockResolvedValue(createdUser); + usersService.findByIdOrFail.mockResolvedValue(safeUser); + + const result = await service.signupComplete({ + signupToken: 'temporary-token', + name: 'New User', + username: 'new_user', + musicRoles: [], + musicGenres: ['Tarab'], + favoriteInstruments: ['Oud'], + favoriteMaqamat: ['Bayati'], + }); + + expect(usersService.createWithOptionalAvatarFile).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'new@example.com', + password: 'hashed-password', + isEmailVerified: true, + }), + undefined, + ); + expect(result).toMatchObject({ + message: 'Account created successfully', + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { id: 'user-1' }, + }); + expect(authRepository.markSignupDraftConsumed).toHaveBeenCalledWith('draft-1'); + }); + + it('signup/complete fails with an invalid signup token', async () => { + const { service, usersService } = createDeferredSignupService(); + + await expect( + service.signupComplete({ + signupToken: 'bad-token', + name: 'New User', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled(); + }); + + it('rolls back the created user if complete fails after user creation', async () => { + const createdUser = { + id: 'user-1', + username: 'new_user', + role: 'user', + avatar: 'uploads/users/avatar/avatar.webp', + }; + const { service, usersService, authRepository } = createDeferredSignupService({ + authRepository: { + markSignupDraftConsumed: jest.fn().mockRejectedValue(new Error('draft update failed')), + }, + }); + authRepository.findSignupDraftByTokenHash.mockResolvedValue({ + id: 'draft-1', + email: 'new@example.com', + passwordHash: 'hashed-password', + verifiedAt: new Date(), + signupTokenExpiresAt: new Date(Date.now() + 60_000), + expiresAt: new Date(Date.now() + 60_000), + consumed: false, + }); + usersService.createWithOptionalAvatarFile.mockResolvedValue(createdUser); + + await expect( + service.signupComplete({ + signupToken: 'temporary-token', + name: 'New User', + username: 'new_user', + }), + ).rejects.toThrow('draft update failed'); + expect(usersService.rollbackSignupCreatedUser).toHaveBeenCalledWith( + 'user-1', + 'uploads/users/avatar/avatar.webp', + ); + }); +}); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 01f7382..d1a783f 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,6 +1,10 @@ import { BadRequestException, + ConflictException, ForbiddenException, + GoneException, + HttpException, + HttpStatus, Injectable, Logger, UnauthorizedException, @@ -26,6 +30,9 @@ import { ResetPasswordDto } from './dto/reset-password.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RegisterDto } from './dto/register.dto'; import { SendEmailVerificationDto } from './dto/send-email-verification.dto'; +import { SignupCompleteDto } from './dto/signup-complete.dto'; +import { SignupStartDto } from './dto/signup-start.dto'; +import { SignupVerifyDto } from './dto/signup-verify.dto'; import { SuperAdminLoginDto } from './dto/super-admin-login.dto'; import { VerifyEmailDto } from './dto/verify-email.dto'; import { VerifyResetCodeDto } from './dto/verify-reset-code.dto'; @@ -33,6 +40,13 @@ import { AuthRepository } from './auth.repository'; import { AuthResult, TokenPair } from './types/token-pair.type'; import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; +type UploadedImageFile = { + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; +}; + @Injectable() export class AuthService { private readonly googleOAuthClient = new OAuth2Client(); @@ -94,6 +108,123 @@ export class AuthService { : { message, email: user.email }; } + async signupStart( + dto: SignupStartDto, + ): Promise<{ message: string; email: string; signupId: string; debugCode?: string }> { + if (dto.password !== dto.confirmPassword) { + throw new BadRequestException('Password confirmation does not match'); + } + + const normalizedEmail = dto.email.toLowerCase(); + await this.assertEmailNotUsed(normalizedEmail); + + const saltRounds = this.configService.get('security.bcryptSaltRounds', { infer: true }); + const [passwordHash, code] = await Promise.all([ + hashValue(dto.password, saltRounds), + Promise.resolve(this.generateResetCode()), + ]); + const codeHash = await hashValue(code, saltRounds); + const ttlMinutes = this.getSignupDraftTtlMinutes(); + const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000); + + const draft = await this.authRepository.upsertSignupDraft({ + email: normalizedEmail, + passwordHash, + codeHash, + codeExpiresAt: expiresAt, + expiresAt, + }); + + await this.emailService.sendVerificationCode(normalizedEmail, code, ttlMinutes); + this.logger.log(`Signup draft verification code generated for ${normalizedEmail}`); + + const response = { + message: 'Verification code sent', + email: normalizedEmail, + signupId: draft.id, + }; + const nodeEnv = this.configService.get('nodeEnv', { infer: true }); + return nodeEnv !== 'production' ? { ...response, debugCode: code } : response; + } + + async signupVerify(dto: SignupVerifyDto): Promise<{ message: string; signupToken: string }> { + const normalizedEmail = dto.email.toLowerCase(); + const draft = await this.authRepository.findSignupDraftWithSecrets(dto.signupId); + this.assertUsableSignupDraft(draft, normalizedEmail); + + const maxAttempts = this.getSignupMaxAttempts(); + if (draft.attempts >= maxAttempts) { + throw new HttpException('Too many verification attempts', HttpStatus.TOO_MANY_REQUESTS); + } + + const isMatch = await compareHash(dto.code, draft.codeHash); + if (!isMatch) { + await this.authRepository.incrementSignupDraftAttempts(draft.id); + if (draft.attempts + 1 >= maxAttempts) { + throw new HttpException('Too many verification attempts', HttpStatus.TOO_MANY_REQUESTS); + } + throw new BadRequestException('Invalid verification code'); + } + + const signupToken = randomBytes(32).toString('base64url'); + const signupTokenHash = hashHighEntropyValue(signupToken, this.getSignupTokenHashSecret()); + const signupTokenExpiresAt = new Date(Date.now() + this.getSignupTokenTtlMinutes() * 60 * 1000); + await this.authRepository.markSignupDraftVerified(draft.id, signupTokenHash, signupTokenExpiresAt); + + return { message: 'Email verified', signupToken }; + } + + async signupComplete(dto: SignupCompleteDto, avatarFile?: UploadedImageFile): Promise { + const signupTokenHash = hashHighEntropyValue(dto.signupToken, this.getSignupTokenHashSecret()); + const draft = await this.authRepository.findSignupDraftByTokenHash(signupTokenHash); + this.assertCompletableSignupDraft(draft); + + await this.assertEmailNotUsed(draft.email); + + const username = dto.username?.trim() + ? dto.username.trim() + : await this.generateUniqueUsernameFromEmail(draft.email); + const createdUser = await this.usersService.createWithOptionalAvatarFile( + { + name: dto.name, + stageName: dto.stageName, + username, + email: draft.email, + password: draft.passwordHash, + bio: dto.bio, + location: dto.location, + latitude: dto.latitude, + longitude: dto.longitude, + isPrivate: dto.isPrivate, + musicRoles: dto.musicRoles, + musicGenres: dto.musicGenres, + experienceLevel: dto.experienceLevel, + favoriteInstruments: dto.favoriteInstruments, + favoriteMaqamat: dto.favoriteMaqamat, + isEmailVerified: true, + }, + avatarFile, + ); + + try { + const tokens = await this.generateAndStoreTokenPair( + createdUser.id, + createdUser.username, + createdUser.role ?? 'user', + ); + await this.authRepository.markSignupDraftConsumed(draft.id); + const safeUser = await this.usersService.findByIdOrFail(createdUser.id); + return { + message: 'Account created successfully', + ...tokens, + user: safeUser.toObject() as unknown as Record, + }; + } catch (error) { + await this.usersService.rollbackSignupCreatedUser(createdUser.id, createdUser.avatar); + throw error; + } + } + async login(dto: LoginDto): Promise { const user = await this.usersService.findByEmailWithPassword(dto.email); if (!user || !user.password) { @@ -697,6 +828,62 @@ export class AuthService { ); } + private getSignupTokenHashSecret(): string { + return this.getRefreshTokenHashSecret(); + } + + private getSignupDraftTtlMinutes(): number { + const configured = this.configService.get('signup.draftTtlMinutes', { infer: true }); + return this.clampMinutes(configured ?? 20, 15, 30); + } + + private getSignupTokenTtlMinutes(): number { + const configured = this.configService.get('signup.tokenTtlMinutes', { infer: true }); + return this.clampMinutes(configured ?? 15, 5, 30); + } + + private getSignupMaxAttempts(): number { + return this.configService.get('emailVerification.maxAttempts', { infer: true }) ?? 5; + } + + private clampMinutes(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + + return Math.min(max, Math.max(min, Math.floor(value))); + } + + private async assertEmailNotUsed(email: string): Promise { + const existingUser = await this.usersService.findByEmail(email); + if (existingUser) { + throw new ConflictException('Email already used'); + } + } + + private assertUsableSignupDraft( + draft: Awaited>, + email: string, + ): asserts draft is NonNullable { + if (!draft || draft.consumed || draft.email !== email) { + throw new BadRequestException('Invalid signup draft'); + } + if (draft.expiresAt <= new Date() || draft.codeExpiresAt <= new Date()) { + throw new GoneException('Signup draft expired'); + } + } + + private assertCompletableSignupDraft( + draft: Awaited>, + ): asserts draft is NonNullable { + if (!draft || !draft.verifiedAt || !draft.signupTokenExpiresAt) { + throw new UnauthorizedException('Invalid or expired signup token'); + } + if (draft.expiresAt <= new Date() || draft.signupTokenExpiresAt <= new Date()) { + throw new UnauthorizedException('Invalid or expired signup token'); + } + } + private getSuperAdminPermissions(): string[] { return [...DEFAULT_SUPERADMIN_PERMISSIONS]; } diff --git a/src/modules/auth/dto/signup-complete.dto.ts b/src/modules/auth/dto/signup-complete.dto.ts new file mode 100644 index 0000000..98f682c --- /dev/null +++ b/src/modules/auth/dto/signup-complete.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + Length, + Max, + Matches, + Min, +} from 'class-validator'; +import { ExperienceLevel } from '../../../common/enums/experience-level.enum'; +import { MusicRole } from '../../../common/enums/music-role.enum'; +import { toStringArray } from '../../../common/utils/array-transform.util'; +import { toBoolean } from '../../../common/utils/query-transform.util'; + +export class SignupCompleteDto { + @ApiProperty() + @IsString() + signupToken!: string; + + @ApiProperty({ required: false, enum: MusicRole, isArray: true }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsEnum(MusicRole, { each: true }) + musicRoles?: MusicRole[]; + + @ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsString({ each: true }) + musicGenres?: string[]; + + @ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER }) + @IsOptional() + @IsEnum(ExperienceLevel) + experienceLevel?: ExperienceLevel; + + @ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsString({ each: true }) + favoriteInstruments?: string[]; + + @ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsString({ each: true }) + favoriteMaqamat?: string[]; + + @ApiProperty({ required: false, example: 'Artist One' }) + @IsOptional() + @IsString() + @Length(0, 80) + stageName?: string; + + @ApiProperty({ example: 'John Doe' }) + @IsString() + @Length(2, 80) + name!: string; + + @ApiProperty({ required: false, example: 'john_doe' }) + @IsOptional() + @IsString() + @Length(3, 30) + @Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' }) + username?: string; + + @ApiProperty({ required: false, maxLength: 160 }) + @IsOptional() + @IsString() + @Length(0, 160) + bio?: string; + + @ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' }) + @IsOptional() + @IsString() + @Length(0, 120) + location?: string; + + @ApiProperty({ required: false, example: 24.7136, minimum: -90, maximum: 90 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiProperty({ required: false, example: 46.6753, minimum: -180, maximum: 180 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; + + @ApiProperty({ required: false, default: false }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isPrivate?: boolean; +} diff --git a/src/modules/auth/dto/signup-start.dto.ts b/src/modules/auth/dto/signup-start.dto.ts new file mode 100644 index 0000000..2a79238 --- /dev/null +++ b/src/modules/auth/dto/signup-start.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, Length } from 'class-validator'; + +export class SignupStartDto { + @ApiProperty({ example: 'john@example.com' }) + @IsEmail() + email!: string; + + @ApiProperty({ minLength: 8 }) + @IsString() + @Length(8, 64) + password!: string; + + @ApiProperty({ minLength: 8 }) + @IsString() + @Length(8, 64) + confirmPassword!: string; +} diff --git a/src/modules/auth/dto/signup-verify.dto.ts b/src/modules/auth/dto/signup-verify.dto.ts new file mode 100644 index 0000000..b46d157 --- /dev/null +++ b/src/modules/auth/dto/signup-verify.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, Length } from 'class-validator'; + +export class SignupVerifyDto { + @ApiProperty() + @IsString() + signupId!: string; + + @ApiProperty({ example: 'john@example.com' }) + @IsEmail() + email!: string; + + @ApiProperty({ example: '123456' }) + @IsString() + @Length(6, 6) + code!: string; +} diff --git a/src/modules/auth/schemas/signup-draft.schema.ts b/src/modules/auth/schemas/signup-draft.schema.ts new file mode 100644 index 0000000..4ac746a --- /dev/null +++ b/src/modules/auth/schemas/signup-draft.schema.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type SignupDraftDocument = HydratedDocument; + +@Schema({ timestamps: true, versionKey: false }) +export class SignupDraft { + @Prop({ required: true, trim: true, lowercase: true, index: true }) + email!: string; + + @Prop({ required: true, select: false }) + passwordHash!: string; + + @Prop({ required: true, select: false }) + codeHash!: string; + + @Prop({ required: true }) + codeExpiresAt!: Date; + + @Prop({ type: Date, default: null }) + verifiedAt?: Date | null; + + @Prop({ type: String, default: '', select: false }) + signupTokenHash!: string; + + @Prop({ type: Date, default: null }) + signupTokenExpiresAt?: Date | null; + + @Prop({ default: 0, min: 0 }) + attempts!: number; + + @Prop({ default: false, index: true }) + consumed!: boolean; + + @Prop({ required: true }) + expiresAt!: Date; +} + +export const SignupDraftSchema = SchemaFactory.createForClass(SignupDraft); + +SignupDraftSchema.index({ email: 1, consumed: 1, expiresAt: -1 }); +SignupDraftSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index cfee639..d22b3e5 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -102,6 +102,36 @@ export class UsersService { }); } + async createWithOptionalAvatarFile( + dto: CreateUserDto & { password: string; role?: UserRole }, + avatarFile?: UploadedImageFile, + ): Promise { + let uploadedAvatarUrl = ''; + + try { + if (avatarFile) { + uploadedAvatarUrl = await this.saveAvatarFile(avatarFile); + } + + return await this.create({ + ...dto, + ...(uploadedAvatarUrl ? { avatar: uploadedAvatarUrl } : {}), + }); + } catch (error) { + if (uploadedAvatarUrl) { + await this.deleteManagedUpload(uploadedAvatarUrl); + } + throw error; + } + } + + async rollbackSignupCreatedUser(userId: string, avatarUrl?: string): Promise { + await this.usersRepository.deleteById(userId); + if (avatarUrl) { + await this.deleteManagedUpload(avatarUrl); + } + } + async createAdminBySuperAdmin( superAdminIdentifier: string, dto: CreateAdminDto,