diff --git a/apps/api/prisma/migrations/20260915130000_user_avatar/migration.sql b/apps/api/prisma/migrations/20260915130000_user_avatar/migration.sql new file mode 100644 index 0000000..eb41e9d --- /dev/null +++ b/apps/api/prisma/migrations/20260915130000_user_avatar/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "avatarStorageKey" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4b6ca5c..438547c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -197,6 +197,8 @@ model User { passwordHash String fullName String role UserRole @default(sales) + /// مسیر فایل آواتار در فضای آپلود + avatarStorageKey String? emailVerifiedAt DateTime? mobileVerifiedAt DateTime? organizationId String diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index dc6e39d..20d4a3f 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index ce98684..f69ddcc 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 = { + '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 { + 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 { + 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 { + 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 { + const rows = await this.prisma.$queryRaw>` + 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 { + 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 { 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 } : {}), }; } diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts index 1b7f442..869b9a5 100644 --- a/apps/api/src/auth/dto.ts +++ b/apps/api/src/auth/dto.ts @@ -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; +} diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index 9be9dfe..e161b32 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -39,6 +39,7 @@ defineProps<{ | 'flag' | 'refresh' | 'x' + | 'x-circle' | 'mic' | 'trash' | 'stop' @@ -50,6 +51,7 @@ defineProps<{ | 'layers' | 'target' | 'pencil' + | 'edit' | 'grid' | 'list' | 'eye' @@ -61,7 +63,8 @@ defineProps<{ | 'medal' | 'logout' | 'pulse' - | 'sliders'; + | 'sliders' + | 'camera'; size?: number; }>(); @@ -253,6 +256,10 @@ defineProps<{ + + + diff --git a/apps/web/src/components/dashboard/HomeHero.vue b/apps/web/src/components/dashboard/HomeHero.vue index ad65b3a..c94288b 100644 --- a/apps/web/src/components/dashboard/HomeHero.vue +++ b/apps/web/src/components/dashboard/HomeHero.vue @@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router'; import UiIcon from '@/components/UiIcon.vue'; import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue'; import { brand, type HomeDashboardAlertDto } from '@mento/shared'; +import { useAuthStore } from '@/stores/auth'; const props = defineProps<{ greeting: string; @@ -18,6 +19,8 @@ const emit = defineEmits<{ ask: []; }>(); +const auth = useAuthStore(); + type HeroChip = { key: string; tone: HomeDashboardAlertDto['tone']; @@ -86,7 +89,7 @@ function alertIcon(tone: string) {
- +
diff --git a/apps/web/src/components/person-profile/ProfileMoreMenu.vue b/apps/web/src/components/person-profile/ProfileMoreMenu.vue index cc76c8d..40b6ffd 100644 --- a/apps/web/src/components/person-profile/ProfileMoreMenu.vue +++ b/apps/web/src/components/person-profile/ProfileMoreMenu.vue @@ -69,6 +69,12 @@ function close() { open.value = false; } +function scheduleClose() { + window.setTimeout(() => { + close(); + }, 0); +} + function onSelect(key: string) { emit('select', key); nextTick(() => { @@ -85,7 +91,7 @@ function onSelect(key: string) { type="button" aria-label="منوی بیشتر" :aria-expanded="open" - @click="toggle" + @click.stop="toggle" > @@ -107,7 +113,7 @@ function onSelect(key: string) { :class="{ danger: item.danger }" role="menuitem" :to="item.to" - @click.stop="close" + @click.stop="scheduleClose" > {{ item.label }} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index b00d83a..c3a76a9 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -337,7 +337,7 @@ onUnmounted(() => { { { const token = ref(readStoredToken()); const user = ref(readStoredUser()); + const avatarObjectUrl = ref(null); const isAuthenticated = computed(() => Boolean(token.value)); + const avatarSrc = computed(() => avatarObjectUrl.value || DEFAULT_AVATAR); + + function clearAvatarUrl() { + if (avatarObjectUrl.value) { + URL.revokeObjectURL(avatarObjectUrl.value); + avatarObjectUrl.value = null; + } + } + + async function refreshAvatar() { + if (!token.value || !user.value?.hasAvatar) { + clearAvatarUrl(); + return; + } + try { + const { data } = await api.get('/auth/me/avatar', { + responseType: 'blob', + }); + if (!data || data.type.includes('json') || data.size === 0) { + clearAvatarUrl(); + return; + } + const next = URL.createObjectURL(data); + clearAvatarUrl(); + avatarObjectUrl.value = next; + } catch { + clearAvatarUrl(); + } + } function persist(payload: LoginResponse) { token.value = payload.accessToken; @@ -43,6 +75,7 @@ export const useAuthStore = defineStore('auth', () => { localStorage.setItem(TOKEN_KEY, payload.accessToken); localStorage.setItem(USER_KEY, JSON.stringify(payload.user)); clearLegacy(); + void refreshAvatar(); } async function login(identifier: string, password: string) { @@ -92,20 +125,62 @@ export const useAuthStore = defineStore('auth', () => { function logout() { token.value = null; user.value = null; + clearAvatarUrl(); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); clearLegacy(); } + function setUser(next: AuthUserDto) { + user.value = next; + localStorage.setItem(USER_KEY, JSON.stringify(next)); + void refreshAvatar(); + } + + async function hydrate() { + if (!token.value) return; + try { + const { data } = await api.get('/auth/me'); + setUser(data); + } catch { + /* interceptor handles 401 */ + } + } + + async function updateProfile(payload: UpdateProfileRequest) { + const { data } = await api.patch('/auth/me', payload); + setUser(data); + } + + async function uploadAvatar(file: File) { + const body = new FormData(); + body.append('file', file); + const { data } = await api.post('/auth/me/avatar', body); + setUser(data); + } + + async function removeAvatar() { + const { data } = await api.delete('/auth/me/avatar'); + setUser(data); + } + + if (token.value) void hydrate(); + return { token, user, isAuthenticated, + avatarSrc, login, requestLoginOtp, verifyLoginOtp, startRegister, verifyRegister, logout, + setUser, + hydrate, + updateProfile, + uploadAvatar, + removeAvatar, }; }); diff --git a/apps/web/src/views/MessageAutomationsView.vue b/apps/web/src/views/MessageAutomationsView.vue index 226c928..531a945 100644 --- a/apps/web/src/views/MessageAutomationsView.vue +++ b/apps/web/src/views/MessageAutomationsView.vue @@ -280,8 +280,21 @@ async function toggle(row: MessageAutomationRuleDto) { } } -async function remove(row: MessageAutomationRuleDto) { - if (!confirm(`قانون «${row.name}» حذف شود؟`)) return; +const pendingDelete = ref(null); + +function askRemove(row: MessageAutomationRuleDto) { + menuId.value = null; + pendingDelete.value = row; +} + +function cancelRemove() { + pendingDelete.value = null; +} + +async function confirmRemove() { + const row = pendingDelete.value; + if (!row) return; + pendingDelete.value = null; try { await api.delete(`/message-automations/${row.id}`); if (editingId.value === row.id) resetForm(); @@ -410,7 +423,7 @@ onUnmounted(() => {
- +
@@ -1071,23 +1084,25 @@ onUnmounted(() => { {{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }} {{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }} - - - +
+ + + +
@@ -1123,12 +1138,24 @@ onUnmounted(() => {

{{ row.description || row.body }}

- +

هنوز قانون پیامی نیست.

+ +
+ + +
+ +
@@ -1678,6 +1705,37 @@ textarea { background: rgba(15, 23, 42, 0.32); } +.confirm-box { + position: relative; + z-index: 1; + width: min(22rem, 100%); + display: grid; + gap: 0.85rem; + padding: 1.15rem 1.2rem 1.1rem; + border-radius: 18px; + background: #fff; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16); +} + +.confirm-box h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 800; + color: #0f172a; +} + +.confirm-box p { + margin: 0; + color: #475569; + font-size: 0.95rem; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + .sheet { position: relative; z-index: 1; @@ -1802,50 +1860,59 @@ textarea { } .desk-table .ops { - display: flex; - align-items: center; - gap: 0.3rem; - flex-wrap: nowrap; + display: table-cell; width: 1%; white-space: nowrap; + vertical-align: middle; +} + +.ops-row { + display: inline-flex; + align-items: center; + gap: 0.35rem; } .icon-btn { - display: grid; + display: inline-grid; place-items: center; - width: 36px; - height: 36px; + flex: 0 0 2.75rem; + width: 2.75rem; + height: 2.75rem; padding: 0; - border: 1px solid #e2e8f0; - border-radius: var(--radius); - background: #fff; + border: 0; + border-radius: 8px; + background: transparent; color: #64748b; cursor: pointer; } .icon-btn:hover { - background: #f8fafc; + background: transparent; color: #0f172a; } +.icon-btn.edit { + color: #2563eb; +} + +.icon-btn.edit:hover { + color: #1d4ed8; +} + .icon-btn.danger { - color: #b91c1c; - border-color: #fecaca; + color: #dc2626; } .icon-btn.danger:hover { - background: #fef2f2; + color: #b91c1c; + background: transparent; } .icon-btn.status { - border-color: #fecaca; - background: #fef2f2; color: #dc2626; } .icon-btn.status.on { - border-color: #bbf7d0; - background: #f0fdf4; color: #16a34a; } diff --git a/apps/web/src/views/ProfileView.vue b/apps/web/src/views/ProfileView.vue index ed53cbe..681d8a7 100644 --- a/apps/web/src/views/ProfileView.vue +++ b/apps/web/src/views/ProfileView.vue @@ -1,6 +1,7 @@