Creating & Edit profile
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "avatarStorageKey" TEXT;
|
||||
@@ -197,6 +197,8 @@ model User {
|
||||
passwordHash String
|
||||
fullName String
|
||||
role UserRole @default(sales)
|
||||
/// مسیر فایل آواتار در فضای آپلود
|
||||
avatarStorageKey String?
|
||||
emailVerifiedAt DateTime?
|
||||
mobileVerifiedAt DateTime?
|
||||
organizationId String
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
LoginDto,
|
||||
@@ -6,10 +19,13 @@ import {
|
||||
LoginOtpVerifyDto,
|
||||
RegisterOrganizationDto,
|
||||
RegisterVerifyOtpDto,
|
||||
UpdateProfileDto,
|
||||
} from './dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
|
||||
type AuthActor = { userId: string; organizationId: string };
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
@@ -44,7 +60,42 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: { userId: string }) {
|
||||
me(@CurrentUser() user: AuthActor) {
|
||||
return this.authService.me(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me')
|
||||
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
|
||||
return this.authService.updateMe(user.userId, dto);
|
||||
}
|
||||
|
||||
/** عکس پروفایل کاربر جاری */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me/avatar')
|
||||
async avatar(@CurrentUser() user: AuthActor) {
|
||||
const file = await this.authService.openAvatar(user.userId);
|
||||
return new StreamableFile(file.stream, { type: file.mimeType });
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('me/avatar')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadAvatar(
|
||||
@CurrentUser() user: AuthActor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.authService.saveAvatar(user.userId, user.organizationId, file);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('me/avatar')
|
||||
removeAvatar(@CurrentUser() user: AuthActor) {
|
||||
return this.authService.removeAvatar(user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,37 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as path from 'path';
|
||||
import type { AuthUserDto, LoginResponse } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginOtpRequestDto,
|
||||
LoginOtpVerifyDto,
|
||||
RegisterOrganizationDto,
|
||||
RegisterVerifyOtpDto,
|
||||
UpdateProfileDto,
|
||||
} from './dto';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
const OTP_TTL_SECONDS = 5 * 60;
|
||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const AVATAR_EXT: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
function slugify(input: string): string {
|
||||
return (
|
||||
@@ -82,6 +94,7 @@ export class AuthService {
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -360,7 +373,146 @@ export class AuthService {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.toAuthUser(user);
|
||||
return this.toAuthUserDto(user);
|
||||
}
|
||||
|
||||
async updateMe(userId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
||||
const fullName = dto.fullName.trim();
|
||||
if (fullName.length < 2) {
|
||||
throw new BadRequestException('نام باید حداقل دو حرف باشد');
|
||||
}
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const mobile = normalizeMobile(dto.mobile);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!existing) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (email !== existing.email) {
|
||||
const taken = await this.prisma.user.findFirst({
|
||||
where: { organizationId: existing.organizationId, email, id: { not: userId } },
|
||||
});
|
||||
if (taken) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
if (mobile !== existing.mobile) {
|
||||
const taken = await this.prisma.user.findFirst({
|
||||
where: { mobile, id: { not: userId } },
|
||||
});
|
||||
if (taken) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { fullName, email, mobile },
|
||||
});
|
||||
return this.toAuthUserDto(user);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این ایمیل یا شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveAvatar(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
): Promise<AuthUserDto> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل تصویر ارسال نشده');
|
||||
}
|
||||
if (file.size > AVATAR_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد');
|
||||
}
|
||||
const mime = (file.mimetype || '').toLowerCase();
|
||||
const ext = AVATAR_EXT[mime];
|
||||
if (!ext) {
|
||||
throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است');
|
||||
}
|
||||
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!existing) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const prevKey = await this.readAvatarKey(userId);
|
||||
const storageKey = path
|
||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
}
|
||||
await this.writeAvatarKey(userId, storageKey);
|
||||
return this.toAuthUserDto(existing);
|
||||
}
|
||||
|
||||
async removeAvatar(userId: string): Promise<AuthUserDto> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!existing) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const prevKey = await this.readAvatarKey(userId);
|
||||
if (prevKey) await this.files.remove(prevKey);
|
||||
await this.writeAvatarKey(userId, null);
|
||||
return this.toAuthUserDto(existing);
|
||||
}
|
||||
|
||||
async openAvatar(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const storageKey = await this.readAvatarKey(userId);
|
||||
if (!storageKey) {
|
||||
throw new NotFoundException('عکس پروفایل تنظیم نشده');
|
||||
}
|
||||
const ext = path.extname(storageKey).slice(1).toLowerCase();
|
||||
const mime =
|
||||
Object.entries(AVATAR_EXT).find(([, value]) => value === ext)?.[0] ??
|
||||
'image/jpeg';
|
||||
return {
|
||||
stream: this.files.openReadStream(storageKey),
|
||||
mimeType: mime,
|
||||
};
|
||||
}
|
||||
|
||||
private async readAvatarKey(userId: string): Promise<string | null> {
|
||||
const rows = await this.prisma.$queryRaw<Array<{ avatarStorageKey: string | null }>>`
|
||||
SELECT "avatarStorageKey" FROM "User" WHERE "id" = ${userId}
|
||||
`;
|
||||
return rows[0]?.avatarStorageKey ?? null;
|
||||
}
|
||||
|
||||
private async writeAvatarKey(userId: string, storageKey: string | null) {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "User" SET "avatarStorageKey" = ${storageKey} WHERE "id" = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
private async toAuthUserDto(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
}): Promise<AuthUserDto> {
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||
});
|
||||
}
|
||||
|
||||
private async throttleChallenges(
|
||||
@@ -435,14 +587,14 @@ export class AuthService {
|
||||
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
|
||||
}
|
||||
|
||||
private buildLoginResponse(user: {
|
||||
private async buildLoginResponse(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
}): LoginResponse {
|
||||
}): Promise<LoginResponse> {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
organizationId: user.organizationId,
|
||||
@@ -450,7 +602,7 @@ export class AuthService {
|
||||
};
|
||||
return {
|
||||
accessToken: this.jwt.sign(payload),
|
||||
user: this.toAuthUser(user),
|
||||
user: await this.toAuthUserDto(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,6 +613,7 @@ export class AuthService {
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
avatarStorageKey?: string | null;
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -468,6 +621,7 @@ export class AuthService {
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,3 +92,18 @@ export class RegisterVerifyOtpDto {
|
||||
@MinLength(4)
|
||||
mobileCode!: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
fullName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||
message: 'شماره موبایل معتبر نیست',
|
||||
})
|
||||
mobile!: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user