Add deferred signup flow
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-30 14:16:56 +03:00
الأصل c064b694d3
التزام 568f679da1
10 ملفات معدلة مع 829 إضافات و3 حذوفات

عرض الملف

@@ -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)

عرض الملف

@@ -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,

عرض الملف

@@ -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<PasswordResetCodeDocument>,
@InjectModel(EmailVerificationCode.name)
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
@InjectModel(SignupDraft.name)
private readonly signupDraftModel: Model<SignupDraftDocument>,
) {}
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
@@ -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<SignupDraftDocument> {
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<SignupDraftDocument | null> {
if (!Types.ObjectId.isValid(id)) {
return null;
}
return this.signupDraftModel
.findById(id)
.select('+passwordHash +codeHash +signupTokenHash')
.exec();
}
async findSignupDraftByTokenHash(signupTokenHash: string): Promise<SignupDraftDocument | null> {
return this.signupDraftModel
.findOne({
signupTokenHash,
consumed: false,
})
.select('+passwordHash +codeHash +signupTokenHash')
.exec();
}
async incrementSignupDraftAttempts(id: string): Promise<void> {
await this.signupDraftModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
}
async markSignupDraftVerified(
id: string,
signupTokenHash: string,
signupTokenExpiresAt: Date,
): Promise<void> {
await this.signupDraftModel
.findByIdAndUpdate(id, {
verifiedAt: new Date(),
signupTokenHash,
signupTokenExpiresAt,
})
.exec();
}
async markSignupDraftConsumed(id: string): Promise<void> {
await this.signupDraftModel.findByIdAndUpdate(id, { consumed: true }).exec();
}
}

عرض الملف

@@ -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<string, jest.Mock>;
authRepository?: Record<string, jest.Mock>;
jwtService?: Record<string, jest.Mock>;
}) => {
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<string, string | number> = {
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',
);
});
});

عرض الملف

@@ -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<number>('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<string>('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<AuthResult & { message: string }> {
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<string, unknown>,
};
} catch (error) {
await this.usersService.rollbackSignupCreatedUser(createdUser.id, createdUser.avatar);
throw error;
}
}
async login(dto: LoginDto): Promise<AuthResult> {
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<number>('signup.draftTtlMinutes', { infer: true });
return this.clampMinutes(configured ?? 20, 15, 30);
}
private getSignupTokenTtlMinutes(): number {
const configured = this.configService.get<number>('signup.tokenTtlMinutes', { infer: true });
return this.clampMinutes(configured ?? 15, 5, 30);
}
private getSignupMaxAttempts(): number {
return this.configService.get<number>('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<void> {
const existingUser = await this.usersService.findByEmail(email);
if (existingUser) {
throw new ConflictException('Email already used');
}
}
private assertUsableSignupDraft(
draft: Awaited<ReturnType<AuthRepository['findSignupDraftWithSecrets']>>,
email: string,
): asserts draft is NonNullable<typeof draft> {
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<ReturnType<AuthRepository['findSignupDraftByTokenHash']>>,
): asserts draft is NonNullable<typeof draft> {
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];
}

عرض الملف

@@ -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;
}

عرض الملف

@@ -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;
}

عرض الملف

@@ -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;
}

عرض الملف

@@ -0,0 +1,42 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type SignupDraftDocument = HydratedDocument<SignupDraft>;
@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 });

عرض الملف

@@ -102,6 +102,36 @@ export class UsersService {
});
}
async createWithOptionalAvatarFile(
dto: CreateUserDto & { password: string; role?: UserRole },
avatarFile?: UploadedImageFile,
): Promise<UserDocument> {
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<void> {
await this.usersRepository.deleteById(userId);
if (avatarUrl) {
await this.deleteManagedUpload(avatarUrl);
}
}
async createAdminBySuperAdmin(
superAdminIdentifier: string,
dto: CreateAdminDto,