Creating & Edit profile

This commit is contained in:
2026-09-15 14:10:52 +03:30
parent 8dcf4ed5e9
commit 9ae176bec0
13 changed files with 767 additions and 77 deletions
+158 -4
View File
@@ -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 } : {}),
};
}