first commit
This commit is contained in:
68
apps/api-gateway/src/controllers/addresses.controller.ts
Normal file
68
apps/api-gateway/src/controllers/addresses.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { UpsertAddressDto } from '../dto/addresses.dto';
|
||||
|
||||
@ApiTags('Адреса')
|
||||
@ApiBearerAuth()
|
||||
@Controller('addresses/users/:userId')
|
||||
export class AddressesController {
|
||||
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: 'Список адресов пользователя' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return firstValueFrom(
|
||||
this.core.addresses.ListAddresses({ userId }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { addresses?: unknown[] };
|
||||
return { addresses: payload.addresses ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Создать или обновить адрес' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpsertAddressDto })
|
||||
async upsert(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpsertAddressDto
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return firstValueFrom(this.core.addresses.UpsertAddress({ userId, ...dto }));
|
||||
}
|
||||
|
||||
@Delete(':addressId')
|
||||
@ApiOperation({ summary: 'Удалить адрес' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'addressId', description: 'ID адреса' })
|
||||
@ApiResponse({ status: 200, description: 'Адрес удалён' })
|
||||
async delete(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('addressId') addressId: string
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return firstValueFrom(this.core.addresses.DeleteAddress({ userId, addressId }));
|
||||
}
|
||||
}
|
||||
69
apps/api-gateway/src/controllers/admin.controller.ts
Normal file
69
apps/api-gateway/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto';
|
||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
||||
|
||||
@ApiTags('Администрирование')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/users')
|
||||
export class AdminController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список пользователей', description: 'Возвращает пользователей для административной таблицы.' })
|
||||
listUsers(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
if (!admin.canViewUsers && !admin.canManageUsers) {
|
||||
throw new ForbiddenException('Недостаточно прав для просмотра пользователей');
|
||||
}
|
||||
return this.core.admin.ListUsers(query).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { users?: Array<{ roles?: string[] }> };
|
||||
return {
|
||||
users: (payload.users ?? []).map((user) => ({
|
||||
...user,
|
||||
roles: user.roles ?? []
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':userId')
|
||||
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpdateUserDto })
|
||||
updateUser(@Param('userId') userId: string, @Body() dto: UpdateUserDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageUsers');
|
||||
return this.core.admin.UpdateUserProfile({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Post(':userId/reset-password')
|
||||
@ApiOperation({ summary: 'Сбросить пароль', description: 'Назначает пользователю новый пароль.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: ResetPasswordDto })
|
||||
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageUsers');
|
||||
return this.core.admin.ResetPassword({ userId, newPassword: dto.newPassword });
|
||||
}
|
||||
|
||||
@Post(':userId/suspend')
|
||||
@ApiOperation({ summary: 'Заблокировать пользователя', description: 'Переводит пользователя в статус SUSPENDED и отзывает активные сессии.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
suspend(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageUsers');
|
||||
return this.core.admin.SuspendUser({ userId });
|
||||
}
|
||||
|
||||
@Patch(':userId/super-admin')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Выдать или снять права супер-администратора', description: 'Доступно только супер-администратору.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: SetSuperAdminDto })
|
||||
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
|
||||
}
|
||||
}
|
||||
42
apps/api-gateway/src/controllers/advanced-auth.controller.ts
Normal file
42
apps/api-gateway/src/controllers/advanced-auth.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('Биометрия и QR-вход')
|
||||
@Controller('auth/advanced')
|
||||
export class AdvancedAuthController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Post('webauthn/register/challenge')
|
||||
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
|
||||
@ApiBody({ type: WebAuthnDto })
|
||||
@ApiResponse({ status: 201, description: 'Challenge создан' })
|
||||
webAuthnRegister(@Body() dto: WebAuthnDto) {
|
||||
return this.core.advancedAuth.CreateWebAuthnRegistrationChallenge(dto);
|
||||
}
|
||||
|
||||
@Post('webauthn/login/challenge')
|
||||
@ApiOperation({ summary: 'Challenge входа WebAuthn', description: 'Создает challenge для входа по лицу или отпечатку.' })
|
||||
@ApiBody({ type: WebAuthnDto })
|
||||
@ApiResponse({ status: 201, description: 'Challenge создан' })
|
||||
webAuthnLogin(@Body() dto: WebAuthnDto) {
|
||||
return this.core.advancedAuth.CreateWebAuthnLoginChallenge(dto);
|
||||
}
|
||||
|
||||
@Post('qr/session')
|
||||
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
|
||||
@ApiBody({ type: QrSessionDto })
|
||||
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
|
||||
createQr(@Body() dto: QrSessionDto) {
|
||||
return this.core.advancedAuth.CreateQrSession(dto);
|
||||
}
|
||||
|
||||
@Get('qr/session/:sessionId')
|
||||
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' })
|
||||
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
||||
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
|
||||
pollQr(@Param('sessionId') sessionId: string) {
|
||||
return this.core.advancedAuth.PollQrSession({ sessionId });
|
||||
}
|
||||
}
|
||||
129
apps/api-gateway/src/controllers/auth.controller.ts
Normal file
129
apps/api-gateway/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Body, Controller, Get, Headers, Post } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto } from '../dto/auth.dto';
|
||||
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
|
||||
|
||||
@ApiTags('Аутентификация')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Регистрация пользователя', description: 'Создает пользователя. Первый пользователь безопасно получает права super admin.' })
|
||||
@ApiBody({ type: RegisterDto })
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.core.auth.Register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.core.auth.Login(dto);
|
||||
}
|
||||
|
||||
@Post('identify')
|
||||
@ApiOperation({ summary: 'Проверить способ входа', description: 'Identifier-first шаг: проверяет, существует ли пользователь, задан ли пароль и включен ли PIN.' })
|
||||
@ApiBody({ type: IdentifyDto })
|
||||
identify(@Body() dto: IdentifyDto) {
|
||||
return this.core.auth.Identify(dto);
|
||||
}
|
||||
|
||||
@Post('otp/send')
|
||||
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
|
||||
@ApiBody({ type: PasswordlessOtpDto })
|
||||
sendOtp(@Body() dto: PasswordlessOtpDto) {
|
||||
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel });
|
||||
}
|
||||
|
||||
@Post('otp/verify')
|
||||
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
|
||||
@ApiBody({ type: PasswordlessVerifyDto })
|
||||
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
|
||||
return this.core.auth.VerifyOtp(dto);
|
||||
}
|
||||
|
||||
@Post('login/password')
|
||||
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
|
||||
@ApiBody({ type: PasswordLoginDto })
|
||||
loginWithPassword(@Body() dto: PasswordLoginDto) {
|
||||
return this.core.auth.LoginWithPassword(dto);
|
||||
}
|
||||
|
||||
@Post('ldap/login')
|
||||
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
|
||||
@ApiBody({ type: LdapLoginDto })
|
||||
loginWithLdap(@Body() dto: LdapLoginDto) {
|
||||
return this.core.auth.LoginWithLdap(dto);
|
||||
}
|
||||
|
||||
@Post('pin/verify')
|
||||
@ApiOperation({ summary: 'Подтвердить PIN-код', description: 'Разблокирует временную сессию и выдает JWT с pinVerified=true.' })
|
||||
@ApiBody({ type: VerifyPinDto })
|
||||
verifyPin(@Body() dto: VerifyPinDto) {
|
||||
return this.core.auth.VerifyPin(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
|
||||
@ApiBody({ type: RefreshSessionDto })
|
||||
refresh(@Body() dto: RefreshSessionDto) {
|
||||
return this.core.auth.RefreshSession(dto);
|
||||
}
|
||||
|
||||
@Get('session')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Состояние текущей сессии',
|
||||
description: 'Возвращает профиль и флаг PIN-блокировки. Не выбрасывает пользователя из аккаунта при заблокированном PIN.'
|
||||
})
|
||||
async session(@Headers('authorization') authorization?: string) {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
|
||||
if (!payload.sessionId) {
|
||||
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
|
||||
return { requiresPin: false, sessionId: null, pinVerified: true, user };
|
||||
}
|
||||
|
||||
const validation = (await firstValueFrom(
|
||||
this.core.auth.ValidateSession({
|
||||
userId: payload.sub,
|
||||
sessionId: payload.sessionId,
|
||||
touchActivity: false
|
||||
})
|
||||
)) as { requiresPin: boolean; sessionId: string; pinVerified: boolean };
|
||||
|
||||
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
|
||||
|
||||
if (!validation.requiresPin) {
|
||||
await firstValueFrom(
|
||||
this.core.auth.ValidateSession({
|
||||
userId: payload.sub,
|
||||
sessionId: payload.sessionId,
|
||||
touchActivity: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
requiresPin: validation.requiresPin,
|
||||
sessionId: validation.sessionId,
|
||||
pinVerified: validation.pinVerified,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Текущий пользователь', description: 'Возвращает профиль пользователя по access token.' })
|
||||
async me(@Headers('authorization') authorization?: string) {
|
||||
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
|
||||
}
|
||||
}
|
||||
142
apps/api-gateway/src/controllers/chat.controller.ts
Normal file
142
apps/api-gateway/src/controllers/chat.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import {
|
||||
CreateChatRoomDto,
|
||||
SendChatMessageDto,
|
||||
SetRoomNotificationsMutedDto,
|
||||
UpdateChatRoomDto,
|
||||
VotePollDto
|
||||
} from '../dto/chat.dto';
|
||||
|
||||
@ApiTags('Чат')
|
||||
@ApiBearerAuth()
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private async auth(authorization?: string) {
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
}
|
||||
|
||||
@Get('groups/:groupId/rooms')
|
||||
@ApiOperation({ summary: 'Список чатов семьи' })
|
||||
async listRooms(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.ListRooms({ userId, groupId }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { rooms?: unknown[] };
|
||||
return { rooms: payload.rooms ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/rooms')
|
||||
@ApiOperation({ summary: 'Создать групповой чат' })
|
||||
async createRoom(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: CreateChatRoomDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.CreateRoom({ userId, groupId, name: dto.name, memberUserIds: dto.memberUserIds ?? [] })
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Настройки чата' })
|
||||
async updateRoom(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: UpdateChatRoomDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.UpdateRoomSettings({
|
||||
userId,
|
||||
roomId,
|
||||
name: dto.name,
|
||||
notificationsMuted: dto.notificationsMuted
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('rooms/:roomId/messages')
|
||||
@ApiOperation({ summary: 'Сообщения чата' })
|
||||
async listMessages(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Query('beforeMessageId') beforeMessageId?: string,
|
||||
@Query('limit') limit?: string
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.ListMessages({
|
||||
userId,
|
||||
roomId,
|
||||
beforeMessageId,
|
||||
limit: limit ? Number(limit) : 50
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { messages?: unknown[] };
|
||||
return { messages: payload.messages ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/messages')
|
||||
@ApiOperation({ summary: 'Отправить сообщение' })
|
||||
async sendMessage(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: SendChatMessageDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.SendMessage({
|
||||
userId,
|
||||
roomId,
|
||||
type: dto.type,
|
||||
content: dto.content,
|
||||
replyToId: dto.replyToId,
|
||||
storageKey: dto.storageKey,
|
||||
mimeType: dto.mimeType,
|
||||
metadataJson: dto.metadataJson,
|
||||
poll: dto.poll
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Post('messages/:messageId/vote')
|
||||
@ApiOperation({ summary: 'Голос в опросе' })
|
||||
async votePoll(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('messageId') messageId: string,
|
||||
@Body() dto: VotePollDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.VotePoll({ userId, messageId, optionIds: dto.optionIds }));
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/mute')
|
||||
@ApiOperation({ summary: 'Выключить/включить уведомления чата' })
|
||||
async setMuted(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: SetRoomNotificationsMutedDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted }));
|
||||
}
|
||||
}
|
||||
97
apps/api-gateway/src/controllers/documents.controller.ts
Normal file
97
apps/api-gateway/src/controllers/documents.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
|
||||
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
|
||||
|
||||
@ApiTags('Хранилище данных и документы')
|
||||
@ApiBearerAuth()
|
||||
@Controller('documents/users/:userId')
|
||||
export class DocumentsController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Создать документ', description: 'Создает документ пользователя с данными формы. Поля шифруются в SSO Core.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: CreateDocumentDto })
|
||||
async create(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: CreateDocumentDto
|
||||
) {
|
||||
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(
|
||||
this.core.documents.CreateDocument({
|
||||
userId,
|
||||
type: dto.type,
|
||||
number: dto.number ?? '—',
|
||||
issuedAt: dto.issuedAt,
|
||||
expiresAt: dto.expiresAt,
|
||||
metadataJson: dto.metadataJson
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список документов', description: 'Возвращает все документы пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(
|
||||
this.core.documents.ListDocuments({ userId }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { documents?: unknown[] };
|
||||
return { documents: payload.documents ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':documentId')
|
||||
@ApiOperation({ summary: 'Получить документ', description: 'Возвращает один документ пользователя с расшифрованными полями.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'documentId', description: 'ID документа' })
|
||||
async get(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('documentId') documentId: string
|
||||
) {
|
||||
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(this.core.documents.GetDocument({ userId, documentId }));
|
||||
}
|
||||
|
||||
@Patch(':documentId')
|
||||
@ApiOperation({ summary: 'Обновить документ', description: 'Обновляет данные формы документа.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'documentId', description: 'ID документа' })
|
||||
@ApiBody({ type: UpdateDocumentDto })
|
||||
async update(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@Body() dto: UpdateDocumentDto
|
||||
) {
|
||||
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(this.core.documents.UpdateDocument({ userId, documentId, ...dto }));
|
||||
}
|
||||
|
||||
@Delete(':documentId')
|
||||
@ApiOperation({ summary: 'Удалить документ', description: 'Удаляет документ пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'documentId', description: 'ID документа' })
|
||||
@ApiResponse({ status: 200, description: 'Документ удалён' })
|
||||
async delete(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('documentId') documentId: string
|
||||
) {
|
||||
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(this.core.documents.DeleteDocument({ userId, documentId }));
|
||||
}
|
||||
}
|
||||
130
apps/api-gateway/src/controllers/family.controller.ts
Normal file
130
apps/api-gateway/src/controllers/family.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
|
||||
import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
|
||||
|
||||
|
||||
@ApiTags('Семья')
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@Controller('family')
|
||||
|
||||
export class FamilyController {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly core: CoreGrpcService,
|
||||
|
||||
private readonly jwt: JwtService
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
private async auth(authorization?: string) {
|
||||
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Post('groups')
|
||||
|
||||
@ApiOperation({ summary: 'Создать семейную группу' })
|
||||
|
||||
@ApiBody({ type: CreateFamilyGroupDto })
|
||||
|
||||
async createGroup(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateFamilyGroupDto) {
|
||||
|
||||
const userId = await this.auth(authorization);
|
||||
|
||||
if (dto.ownerId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Можно создавать семью только от своего имени');
|
||||
|
||||
}
|
||||
|
||||
return firstValueFrom(this.core.family.CreateFamilyGroup(dto));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Get('users/:userId/groups')
|
||||
|
||||
@ApiOperation({ summary: 'Список семей пользователя' })
|
||||
|
||||
async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
|
||||
const requesterId = await this.auth(authorization);
|
||||
|
||||
if (requesterId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Можно просматривать только свои семьи');
|
||||
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
|
||||
this.core.family.ListFamilyGroups({ userId }).pipe(
|
||||
|
||||
map((response) => {
|
||||
|
||||
const payload = response as { groups?: unknown[] };
|
||||
|
||||
return { groups: payload.groups ?? [] };
|
||||
|
||||
})
|
||||
|
||||
)
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Get('groups/:groupId')
|
||||
|
||||
@ApiOperation({ summary: 'Получить семейную группу' })
|
||||
|
||||
async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
|
||||
const requesterId = await this.auth(authorization);
|
||||
|
||||
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Patch('groups/:groupId')
|
||||
|
||||
@ApiOperation({ summary: 'Обновить семейную группу' })
|
||||
|
||||
async updateGroup(
|
||||
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
|
||||
@Param('groupId') groupId: string,
|
||||
|
||||
@Body() dto: UpdateFamilyGroupDto
|
||||
|
||||
) {
|
||||
|
||||
const requesterId = await this.auth(authorization);
|
||||
|
||||
9
apps/api-gateway/src/controllers/health.controller.ts
Normal file
9
apps/api-gateway/src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'api-gateway' };
|
||||
}
|
||||
}
|
||||
215
apps/api-gateway/src/controllers/media.controller.ts
Normal file
215
apps/api-gateway/src/controllers/media.controller.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { AvatarUploadDto, ChatMediaUploadDto, ConfirmAvatarDto, DocumentPhotoUploadDto } from '../dto/media.dto';
|
||||
import { buildContentDisposition } from '../media-content-disposition';
|
||||
|
||||
type StreamResponse = {
|
||||
setHeader: (key: string, value: string) => void;
|
||||
status: (code: number) => { json: (body: unknown) => void };
|
||||
} & NodeJS.WritableStream;
|
||||
|
||||
@ApiTags('Медиа')
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private getS3Client() {
|
||||
if (!this.s3Client) {
|
||||
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000';
|
||||
const useSsl = process.env.MINIO_USE_SSL === 'true';
|
||||
this.s3Client = new S3Client({
|
||||
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
|
||||
region: process.env.MINIO_REGION ?? 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
}
|
||||
return this.s3Client;
|
||||
}
|
||||
|
||||
private async authUserId(authorization?: string) {
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
}
|
||||
|
||||
@Post('avatars/upload-url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
|
||||
@ApiBody({ type: AvatarUploadDto })
|
||||
async createAvatarUploadUrl(@Headers('authorization') authorization: string | undefined, @Body() dto: AvatarUploadDto) {
|
||||
const userId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.CreateAvatarUploadUrl({ userId, contentType: dto.contentType }));
|
||||
}
|
||||
|
||||
@Post('avatars/confirm')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Подтвердить загрузку аватара', description: 'Сохраняет ключ объекта MinIO как аватар пользователя.' })
|
||||
@ApiBody({ type: ConfirmAvatarDto })
|
||||
async confirmAvatar(@Headers('authorization') authorization: string | undefined, @Body() dto: ConfirmAvatarDto) {
|
||||
const userId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.ConfirmAvatar({ userId, storageKey: dto.storageKey }));
|
||||
}
|
||||
|
||||
@Get('avatars/:userId/url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить временную ссылку на аватар', description: 'Возвращает ссылку, действующую 15 минут. Без авторизации ссылка не выдаётся.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
async getAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.GetAvatarAccessUrl({ requesterId, targetUserId: userId }));
|
||||
}
|
||||
|
||||
@Post('documents/:documentId/photo/upload-url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
|
||||
@ApiBody({ type: DocumentPhotoUploadDto })
|
||||
async createDocumentPhotoUploadUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('documentId') documentId: string,
|
||||
@Body() dto: DocumentPhotoUploadDto
|
||||
) {
|
||||
const userId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType }));
|
||||
}
|
||||
|
||||
@Get('users/:userId/documents/photo-url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
|
||||
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
|
||||
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
|
||||
async getDocumentPhotoUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Query('storageKey') storageKey: string
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey })
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stream/:token')
|
||||
@ApiOperation({ summary: 'Потоковая выдача медиа', description: 'Отдаёт файл по временному токену. Для файлов чата требуется Authorization. Токен действует 15 минут.' })
|
||||
@ApiParam({ name: 'token', description: 'Временный JWT-токен доступа' })
|
||||
@ApiResponse({ status: 403, description: 'Ссылка недействительна или истекла' })
|
||||
async stream(
|
||||
@Param('token') token: string,
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Query('download') download: string | undefined,
|
||||
@Res({ passthrough: false }) response: StreamResponse
|
||||
) {
|
||||
let requesterId: string | undefined;
|
||||
if (authorization) {
|
||||
try {
|
||||
requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
} catch {
|
||||
requesterId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await firstValueFrom(this.core.media.ResolveStreamToken({ token, requesterId }));
|
||||
const payload = resolved as { storageKey: string; contentType: string; fileName?: string };
|
||||
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
|
||||
const object = await this.getS3Client().send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: payload.storageKey
|
||||
})
|
||||
);
|
||||
|
||||
response.setHeader('Content-Type', payload.contentType);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
if (payload.fileName) {
|
||||
const asAttachment = download === '1' || download === 'true';
|
||||
response.setHeader(
|
||||
'Content-Disposition',
|
||||
buildContentDisposition(asAttachment ? 'attachment' : 'inline', payload.fileName)
|
||||
);
|
||||
}
|
||||
const body = object.Body;
|
||||
if (!body) {
|
||||
response.status(404).json({ message: 'Файл не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = body as NodeJS.ReadableStream;
|
||||
stream.pipe(response);
|
||||
}
|
||||
|
||||
@Post('families/:groupId/avatar/upload-url')
|
||||
@ApiBearerAuth()
|
||||
async createFamilyAvatarUploadUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: AvatarUploadDto
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.CreateFamilyAvatarUploadUrl({ requesterId, groupId, contentType: dto.contentType })
|
||||
);
|
||||
}
|
||||
|
||||
@Post('families/:groupId/avatar/confirm')
|
||||
@ApiBearerAuth()
|
||||
async confirmFamilyAvatar(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: ConfirmAvatarDto
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.ConfirmFamilyAvatar({ requesterId, groupId, storageKey: dto.storageKey })
|
||||
);
|
||||
}
|
||||
|
||||
@Get('families/:groupId/avatar/url')
|
||||
@ApiBearerAuth()
|
||||
async getFamilyAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.GetFamilyAvatarAccessUrl({ requesterId, groupId }));
|
||||
}
|
||||
|
||||
@Post('chat/:roomId/media/upload-url')
|
||||
@ApiBearerAuth()
|
||||
async createChatMediaUploadUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: ChatMediaUploadDto
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.CreateChatMediaUploadUrl({
|
||||
requesterId,
|
||||
roomId,
|
||||
contentType: dto.contentType,
|
||||
fileName: dto.fileName
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('chat/:roomId/media/url')
|
||||
@ApiBearerAuth()
|
||||
async getChatMediaUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Query('storageKey') storageKey: string,
|
||||
@Query('fileName') fileName?: string
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName })
|
||||
);
|
||||
}
|
||||
}
|
||||
82
apps/api-gateway/src/controllers/notifications.controller.ts
Normal file
82
apps/api-gateway/src/controllers/notifications.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { MarkNotificationReadDto } from '../dto/notifications.dto';
|
||||
|
||||
@ApiTags('Уведомления')
|
||||
@ApiBearerAuth()
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список уведомлений' })
|
||||
async list(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('unreadOnly') unreadOnly?: string
|
||||
) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(
|
||||
this.core.notifications.ListNotifications({
|
||||
userId,
|
||||
limit: limit ? Number(limit) : 30,
|
||||
unreadOnly: unreadOnly === 'true'
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { notifications?: unknown[] };
|
||||
return { notifications: payload.notifications ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
|
||||
async unreadCount(@Headers('authorization') authorization: string | undefined) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
|
||||
}
|
||||
|
||||
@Patch(':notificationId/read')
|
||||
@ApiOperation({ summary: 'Удалить просмотренное уведомление' })
|
||||
async markRead(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('notificationId') notificationId: string,
|
||||
@Body() _dto: MarkNotificationReadDto
|
||||
) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
|
||||
}
|
||||
|
||||
@Delete(':notificationId')
|
||||
@ApiOperation({ summary: 'Удалить уведомление' })
|
||||
async deleteNotification(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('notificationId') notificationId: string
|
||||
) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
|
||||
}
|
||||
|
||||
@Post('read-all')
|
||||
@ApiOperation({ summary: 'Удалить все уведомления' })
|
||||
async markAllRead(@Headers('authorization') authorization: string | undefined) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@ApiOperation({ summary: 'Удалить все уведомления' })
|
||||
async deleteAll(@Headers('authorization') authorization: string | undefined) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
||||
}
|
||||
}
|
||||
38
apps/api-gateway/src/controllers/oauth.controller.ts
Normal file
38
apps/api-gateway/src/controllers/oauth.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
|
||||
import { extractBearerToken } from '../auth-token';
|
||||
|
||||
@ApiTags('OAuth 2.0')
|
||||
@Controller('oauth')
|
||||
export class OAuthController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('authorize')
|
||||
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
|
||||
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
|
||||
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
|
||||
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
|
||||
@ApiResponse({ status: 200, description: 'Authorization code создан' })
|
||||
authorize(@Query() query: OAuthAuthorizeQueryDto) {
|
||||
return this.core.oauth.Authorize(query);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
||||
@ApiBody({ type: OAuthTokenDto })
|
||||
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
||||
token(@Body() dto: OAuthTokenDto) {
|
||||
return this.core.oauth.Token(dto);
|
||||
}
|
||||
|
||||
@Get('userinfo')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
|
||||
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
|
||||
userInfo(@Headers('authorization') authorization?: string) {
|
||||
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
|
||||
}
|
||||
}
|
||||
26
apps/api-gateway/src/controllers/otp.controller.ts
Normal file
26
apps/api-gateway/src/controllers/otp.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { SendOtpDto, VerifyOtpDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('OTP и 2FA')
|
||||
@Controller('auth/otp')
|
||||
export class OtpController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Post('send')
|
||||
@ApiOperation({ summary: 'Отправить OTP код', description: 'Создает одноразовый код в AuthCode. Fallback-доставка пишет код в console.log.' })
|
||||
@ApiBody({ type: SendOtpDto })
|
||||
@ApiResponse({ status: 201, description: 'Код создан и отправлен' })
|
||||
send(@Body() dto: SendOtpDto) {
|
||||
return this.core.otp.SendOtp(dto);
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
@ApiOperation({ summary: 'Проверить OTP код', description: 'Проверяет одноразовый код, срок жизни и помечает его использованным.' })
|
||||
@ApiBody({ type: VerifyOtpDto })
|
||||
@ApiResponse({ status: 201, description: 'Код подтвержден' })
|
||||
verify(@Body() dto: VerifyOtpDto) {
|
||||
return this.core.otp.VerifyOtp(dto);
|
||||
}
|
||||
}
|
||||
83
apps/api-gateway/src/controllers/profile.controller.ts
Normal file
83
apps/api-gateway/src/controllers/profile.controller.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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 });
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
@ApiTags('Публичные настройки')
|
||||
@Controller('settings')
|
||||
export class PublicSettingsController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('public')
|
||||
@ApiOperation({ summary: 'Публичные настройки', description: 'Возвращает несекретные настройки для интерфейса без авторизации.' })
|
||||
listPublic() {
|
||||
return this.core.settings.ListPublicSettings({}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { settings?: Array<{ key: string; value: string }> };
|
||||
return { settings: payload.settings ?? [] };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
103
apps/api-gateway/src/controllers/rbac.controller.ts
Normal file
103
apps/api-gateway/src/controllers/rbac.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto';
|
||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
||||
|
||||
@ApiTags('RBAC и OAuth')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/rbac')
|
||||
export class RbacController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('roles')
|
||||
@ApiOperation({ summary: 'Список ролей', description: 'Возвращает роли вместе с назначенными правами.' })
|
||||
listRoles() {
|
||||
return this.core.rbac.ListRoles({}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { roles?: Array<{ permissions?: unknown[] }> };
|
||||
return {
|
||||
roles: (payload.roles ?? []).map((role) => ({
|
||||
...role,
|
||||
permissions: role.permissions ?? []
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
|
||||
listPermissions() {
|
||||
return this.core.rbac.ListPermissions({});
|
||||
}
|
||||
|
||||
@Get('oauth-scopes')
|
||||
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
|
||||
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.ListOAuthScopes({});
|
||||
}
|
||||
|
||||
@Get('oauth-clients')
|
||||
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
|
||||
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.ListOAuthClients({});
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' })
|
||||
@ApiBody({ type: CreateRoleDto })
|
||||
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto });
|
||||
}
|
||||
|
||||
@Post('users/:userId/roles')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: AssignUserRoleDto })
|
||||
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.rbac.AssignUserRole({ actorUserId: admin.id, userId, roleSlug: dto.roleSlug });
|
||||
}
|
||||
|
||||
@Delete('users/:userId/roles/:roleSlug')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
|
||||
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug });
|
||||
}
|
||||
|
||||
@Post('oauth-clients')
|
||||
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
|
||||
@ApiBody({ type: CreateOAuthClientDto })
|
||||
createOAuthClient(@Body() dto: CreateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.CreateOAuthClient({ actorUserId: admin.id, ...dto });
|
||||
}
|
||||
|
||||
@Patch('oauth-clients/:clientId')
|
||||
@ApiOperation({ summary: 'Обновить OAuth-приложение', description: 'Изменяет redirect URI, scopes и статус приложения.' })
|
||||
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
||||
@ApiBody({ type: UpdateOAuthClientDto })
|
||||
updateOAuthClient(@Param('clientId') clientId: string, @Body() dto: UpdateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto });
|
||||
}
|
||||
|
||||
@Post('oauth-clients/:clientId/rotate-secret')
|
||||
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
|
||||
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
||||
rotateSecret(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.RotateOAuthSecret({ actorUserId: admin.id, clientId });
|
||||
}
|
||||
}
|
||||
111
apps/api-gateway/src/controllers/security.controller.ts
Normal file
111
apps/api-gateway/src/controllers/security.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto';
|
||||
|
||||
@ApiTags('Безопасность')
|
||||
@ApiBearerAuth()
|
||||
@Controller('security')
|
||||
export class SecurityController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('users/:userId/devices')
|
||||
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список устройств получен' })
|
||||
listDevices(@Param('userId') userId: string) {
|
||||
return this.core.security.ListActiveDevices({ userId });
|
||||
}
|
||||
|
||||
@Get('users/:userId/sessions')
|
||||
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список активных сессий получен' })
|
||||
listSessions(@Param('userId') userId: string) {
|
||||
return this.core.security.ListActiveSessions({ userId });
|
||||
}
|
||||
|
||||
@Get('users/:userId/sign-in-history')
|
||||
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'История входов получена' })
|
||||
listHistory(@Param('userId') userId: string) {
|
||||
return this.core.security.ListSignInHistory({ userId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/setup')
|
||||
@ApiOperation({ summary: 'Настроить PIN-код', description: 'Создает или включает PIN-код пользователя. PIN хранится только в виде bcrypt hash.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: PinDto })
|
||||
@ApiResponse({ status: 201, description: 'PIN-код настроен' })
|
||||
setupPin(@Param('userId') userId: string, @Body() dto: PinDto) {
|
||||
return this.core.security.SetupPin({ userId, pin: dto.pin });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/update')
|
||||
@ApiOperation({ summary: 'Обновить PIN-код', description: 'Меняет PIN-код и переводит активные сессии пользователя в LOCKED до повторной проверки.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: PinDto })
|
||||
@ApiResponse({ status: 201, description: 'PIN-код обновлен' })
|
||||
updatePin(@Param('userId') userId: string, @Body() dto: PinDto) {
|
||||
return this.core.security.UpdatePin({ userId, pin: dto.pin });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/delete-request')
|
||||
@ApiOperation({ summary: 'Запросить удаление PIN-кода', description: 'Запускает период ожидания перед отключением PIN-кода согласно SystemSetting.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: OptionalPinDto })
|
||||
requestPinDeletion(@Param('userId') userId: string, @Body() dto: OptionalPinDto) {
|
||||
return this.core.security.RequestPinDeletion({ userId, pin: dto.pin ?? '' });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/delete-cancel')
|
||||
@ApiOperation({ summary: 'Отменить удаление PIN-кода', description: 'Отменяет запланированное удаление PIN-кода.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
cancelPinDeletion(@Param('userId') userId: string) {
|
||||
return this.core.security.CancelPinDeletion({ userId });
|
||||
}
|
||||
|
||||
@Post('pin/verify')
|
||||
@ApiOperation({ summary: 'Проверить PIN-код', description: 'Проверяет PIN-код и возвращает access token с pinVerified=true для указанной сессии.' })
|
||||
@ApiBody({ type: VerifySecurityPinDto })
|
||||
@ApiResponse({ status: 201, description: 'PIN-код проверен' })
|
||||
verifyPin(@Body() dto: VerifySecurityPinDto) {
|
||||
return this.core.security.VerifyPinCode(dto);
|
||||
}
|
||||
|
||||
@Post('users/:userId/sessions/:sessionId/lock')
|
||||
@ApiOperation({ summary: 'Заблокировать сессию', description: 'Переводит активную сессию в LOCKED и сбрасывает pinVerified=false.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
|
||||
@ApiResponse({ status: 201, description: 'Сессия заблокирована' })
|
||||
lockSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
|
||||
return this.core.security.LockSession({ userId, sessionId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/sessions/:sessionId/revoke')
|
||||
@ApiOperation({ summary: 'Выйти с устройства', description: 'Отзывает конкретную сессию пользователя и завершает доступ с выбранного устройства.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
|
||||
@ApiResponse({ status: 201, description: 'Сессия отозвана' })
|
||||
revokeSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
|
||||
return this.core.security.RevokeSession({ userId, sessionId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/devices/:deviceId/revoke')
|
||||
@ApiOperation({ summary: 'Завершить сессии устройства', description: 'Отзывает все активные сессии выбранного устройства пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'deviceId', description: 'ID устройства' })
|
||||
@ApiResponse({ status: 201, description: 'Сессии устройства завершены' })
|
||||
revokeDevice(@Param('userId') userId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.core.security.RevokeDevice({ userId, deviceId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/revoke-all-sessions')
|
||||
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
|
||||
revokeAll(@Param('userId') userId: string) {
|
||||
return this.core.security.RevokeAllSessions({ userId });
|
||||
}
|
||||
}
|
||||
125
apps/api-gateway/src/controllers/settings.controller.ts
Normal file
125
apps/api-gateway/src/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
|
||||
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
||||
|
||||
const settingsWritePipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: false
|
||||
});
|
||||
|
||||
@ApiTags('Глобальные настройки')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/settings')
|
||||
export class SettingsController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список системных настроек', description: 'Возвращает динамические настройки, включая PIN timeout и OAuth providers.' })
|
||||
@ApiResponse({ status: 200, description: 'Список настроек получен' })
|
||||
list(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.ListSettings({}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { settings?: unknown[] };
|
||||
return { settings: payload.settings ?? [] };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('key/:key')
|
||||
@ApiOperation({ summary: 'Получить настройку', description: 'Возвращает SystemSetting по ключу.' })
|
||||
@ApiParam({ name: 'key', description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
||||
@ApiResponse({ status: 200, description: 'Настройка получена' })
|
||||
get(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.GetSetting({ key });
|
||||
}
|
||||
|
||||
@Put()
|
||||
@UsePipes(settingsWritePipe)
|
||||
@ApiOperation({ summary: 'Создать или обновить настройку', description: 'Меняет значение SystemSetting без изменения кода frontend.' })
|
||||
@ApiBody({ type: UpsertSettingDto })
|
||||
@ApiResponse({ status: 200, description: 'Настройка создана или обновлена' })
|
||||
upsert(@Body() dto: UpsertSettingDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.UpsertSetting({
|
||||
key: dto.key,
|
||||
value: dto.value,
|
||||
description: dto.description,
|
||||
isSecret: dto.isSecret
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('key/:key')
|
||||
@ApiOperation({ summary: 'Удалить настройку', description: 'Удаляет SystemSetting по ключу.' })
|
||||
@ApiParam({ name: 'key', description: 'Ключ настройки' })
|
||||
@ApiResponse({ status: 200, description: 'Настройка удалена' })
|
||||
delete(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.DeleteSetting({ key });
|
||||
}
|
||||
|
||||
@Get('oauth/providers')
|
||||
@ApiOperation({ summary: 'Список OAuth провайдеров', description: 'Возвращает глобальные настройки Google/Yandex и других social login providers.' })
|
||||
@ApiResponse({ status: 200, description: 'Список провайдеров получен' })
|
||||
listProviders(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.ListSocialProviders({});
|
||||
}
|
||||
|
||||
@Put('oauth/providers')
|
||||
@UsePipes(settingsWritePipe)
|
||||
@ApiOperation({ summary: 'Создать или обновить OAuth провайдера', description: 'Управляет clientId/clientSecret и глобальным включением Google/Yandex входа.' })
|
||||
@ApiBody({ type: UpsertSocialProviderDto })
|
||||
@ApiResponse({ status: 200, description: 'Провайдер создан или обновлен' })
|
||||
upsertProvider(@Body() dto: UpsertSocialProviderDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.UpsertSocialProvider({
|
||||
providerName: dto.providerName,
|
||||
clientId: dto.clientId,
|
||||
clientSecret: dto.clientSecret,
|
||||
isEnabled: dto.isEnabled
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('oauth/providers/:providerName')
|
||||
@ApiOperation({ summary: 'Удалить OAuth провайдера', description: 'Удаляет глобальную настройку social login provider.' })
|
||||
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
|
||||
@ApiResponse({ status: 200, description: 'Провайдер удален' })
|
||||
deleteProvider(@Param('providerName') providerName: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.DeleteSocialProvider({ providerName });
|
||||
}
|
||||
|
||||
@Get('linked-accounts/users/:userId')
|
||||
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список внешних аккаунтов получен' })
|
||||
listLinkedAccounts(@Param('userId') userId: string) {
|
||||
return this.core.security.ListLinkedAccounts({ userId });
|
||||
}
|
||||
|
||||
@Put('linked-accounts/users/:userId')
|
||||
@ApiOperation({ summary: 'Подключить внешний аккаунт', description: 'Создает или обновляет LinkedAccount для social OAuth логина пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: ConnectLinkedAccountDto })
|
||||
@ApiResponse({ status: 200, description: 'Внешний аккаунт подключен' })
|
||||
connectLinkedAccount(@Param('userId') userId: string, @Body() dto: ConnectLinkedAccountDto) {
|
||||
return this.core.security.ConnectLinkedAccount({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Delete('linked-accounts/users/:userId/:providerName')
|
||||
@ApiOperation({ summary: 'Отключить внешний аккаунт', description: 'Удаляет связь пользователя с Google/Yandex или другим social provider.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
|
||||
@ApiResponse({ status: 200, description: 'Внешний аккаунт отключен' })
|
||||
disconnectLinkedAccount(@Param('userId') userId: string, @Param('providerName') providerName: string) {
|
||||
return this.core.security.DisconnectLinkedAccount({ userId, providerName });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user