This commit is contained in:
lendry
2026-06-25 07:23:34 +03:00
parent f2366a69a0
commit 71b270fcb3
19 changed files with 1410 additions and 112 deletions

View File

@@ -1,83 +1,128 @@
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto';
@ApiTags('Профиль и биометрия')
@ApiBearerAuth()
@Controller('profile/users/:userId')
export class ProfileController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async assertSelfAccess(authorization: string | undefined, userId: string) {
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно изменять только свой профиль');
}
return requesterId;
}
@Get()
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
getProfile(@Param('userId') userId: string) {
return this.core.profile.GetProfile({ userId });
}
@Patch('avatar')
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateAvatarDto })
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
}
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import {
ChangePasswordDto,
PasswordVerificationDto,
SendPasswordVerificationOtpDto,
SetPasswordDto,
UpdateAvatarDto,
UpdateContactsDto,
UpdateProfileDto
} from '../dto/profile.dto';
@ApiTags('Профиль и биометрия')
@ApiBearerAuth()
@Controller('profile/users/:userId')
export class ProfileController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async assertSelfAccess(authorization: string | undefined, userId: string) {
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно изменять только свой профиль');
}
return requesterId;
}
@Get()
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
getProfile(@Param('userId') userId: string) {
return this.core.profile.GetProfile({ userId });
}
@Patch('avatar')
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateAvatarDto })
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
}
@Patch()
@ApiOperation({ summary: 'Обновить профиль', description: 'Обновляет firstName, lastName, bio, age и gender в UserProfile, а displayName синхронизирует с именем и фамилией.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateProfileDto })
@ApiResponse({ status: 200, description: 'Профиль обновлен' })
updateProfile(@Param('userId') userId: string, @Body() dto: UpdateProfileDto) {
return this.core.profile.UpdateProfile({ userId, ...dto });
}
@Patch('contacts')
@ApiOperation({ summary: 'Обновить контакты', description: 'Обновляет основную и резервную почту/телефон в модели User.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateContactsDto })
@ApiResponse({ status: 200, description: 'Контакты обновлены' })
@ApiResponse({ status: 400, description: 'Переданы некорректные контактные данные' })
updateContacts(@Param('userId') userId: string, @Body() dto: UpdateContactsDto) {
return this.core.profile.UpdateContacts({ userId, ...dto });
}
@Post('password')
@ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта без ранее заданного пароля.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: SetPasswordDto })
@ApiResponse({ status: 201, description: 'Пароль установлен' })
async setPassword(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: SetPasswordDto
) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.SetPassword({ userId, password: dto.password });
}
@Post('password/otp')
@ApiOperation({ summary: 'Отправить OTP для смены пароля' })
async sendPasswordVerificationOtp(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: SendPasswordVerificationOtpDto
) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.SendPasswordVerificationOtp({ userId, channel: dto.channel });
}
@Patch('password')
@ApiOperation({ summary: 'Сменить пароль с подтверждением личности' })
async changePassword(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: ChangePasswordDto
) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.ChangePassword({ userId, ...dto });
}
@Delete('password')
@ApiOperation({ summary: 'Удалить пароль с подтверждением личности' })
async removePassword(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: PasswordVerificationDto
) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.RemovePassword({ userId, ...dto });
}
@Post('self-delete')
@ApiOperation({
summary: 'Удалить свой профиль',
description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.'
})
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 201, description: 'Профиль удалён' })
async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.SoftDeleteProfile({ userId });
}
}

View File

@@ -76,6 +76,41 @@ export class SetPasswordDto {
password!: string;
}
export class SendPasswordVerificationOtpDto {
@ApiProperty({ description: 'Канал OTP', enum: ['sms', 'email'] })
@IsString({ message: 'Канал должен быть строкой' })
channel!: 'sms' | 'email';
}
export class PasswordVerificationDto {
@ApiPropertyOptional({ description: 'Текущий пароль' })
@IsOptional()
@IsString({ message: 'Текущий пароль должен быть строкой' })
currentPassword?: string;
@ApiPropertyOptional({ description: 'OTP-код из SMS или почты' })
@IsOptional()
@IsString({ message: 'OTP-код должен быть строкой' })
otpCode?: string;
@ApiPropertyOptional({ description: 'Канал OTP', enum: ['sms', 'email'] })
@IsOptional()
@IsString({ message: 'Канал OTP должен быть строкой' })
otpChannel?: 'sms' | 'email';
@ApiPropertyOptional({ description: 'Код из приложения-аутентификатора' })
@IsOptional()
@IsString({ message: 'Код аутентификатора должен быть строкой' })
totpCode?: string;
}
export class ChangePasswordDto extends PasswordVerificationDto {
@ApiProperty({ description: 'Новый пароль', minLength: 8 })
@IsString({ message: 'Пароль должен быть строкой' })
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
newPassword!: string;
}
export class UserIdParamDto {
@ApiProperty({ description: 'ID пользователя' })
@IsString({ message: 'ID пользователя должен быть строкой' })