fix sso core folder
This commit is contained in:
90
apps/sso-core/src/domain/access.service.ts
Normal file
90
apps/sso-core/src/domain/access.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
export interface UserAccessContext {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
canAccessAdmin: boolean;
|
||||
canManageRoles: boolean;
|
||||
canManageOAuth: boolean;
|
||||
canManageUsers: boolean;
|
||||
canViewUsers: boolean;
|
||||
canManageSettings: boolean;
|
||||
canViewUserDocuments: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getUserAccess(userId: string, isSuperAdmin: boolean): Promise<UserAccessContext> {
|
||||
if (isSuperAdmin) {
|
||||
return {
|
||||
roles: ['super-admin'],
|
||||
permissions: ['admin.access', 'oauth.manage', 'users.manage', 'users.read', 'settings.manage', 'rbac.manage', 'documents.view_others'],
|
||||
canAccessAdmin: true,
|
||||
canManageRoles: true,
|
||||
canManageOAuth: true,
|
||||
canManageUsers: true,
|
||||
canViewUsers: true,
|
||||
canManageSettings: true,
|
||||
canViewUserDocuments: true
|
||||
};
|
||||
}
|
||||
|
||||
const links = await this.prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const roles = links.map((link) => link.role.slug);
|
||||
const permissions = [...new Set(links.flatMap((link) => link.role.permissions.map((item) => item.permission.slug)))];
|
||||
|
||||
return {
|
||||
roles,
|
||||
permissions,
|
||||
canAccessAdmin: permissions.includes('admin.access'),
|
||||
canManageRoles: false,
|
||||
canManageOAuth: permissions.includes('oauth.manage'),
|
||||
canManageUsers: permissions.includes('users.manage'),
|
||||
canViewUsers: permissions.includes('users.manage') || permissions.includes('users.read'),
|
||||
canManageSettings: permissions.includes('settings.manage'),
|
||||
canViewUserDocuments: permissions.includes('documents.view_others')
|
||||
};
|
||||
}
|
||||
|
||||
async assertSuperAdmin(actorUserId: string) {
|
||||
const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } });
|
||||
if (!actor?.isSuperAdmin) {
|
||||
throw new ForbiddenException('Только супер-администратор может выполнять это действие');
|
||||
}
|
||||
}
|
||||
|
||||
async assertPermission(userId: string, isSuperAdmin: boolean, permission: string) {
|
||||
const access = await this.getUserAccess(userId, isSuperAdmin);
|
||||
if (!access.permissions.includes(permission) && !isSuperAdmin) {
|
||||
throw new Error('FORBIDDEN_PERMISSION');
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
async assertCanViewUserDocuments(requesterId: string, targetUserId: string) {
|
||||
if (requesterId === targetUserId) return;
|
||||
const requester = await this.prisma.user.findUnique({ where: { id: requesterId } });
|
||||
if (!requester) {
|
||||
throw new ForbiddenException('Пользователь не найден');
|
||||
}
|
||||
const access = await this.getUserAccess(requesterId, requester.isSuperAdmin);
|
||||
if (!access.canViewUserDocuments) {
|
||||
throw new ForbiddenException('Нет доступа к документам пользователя');
|
||||
}
|
||||
}
|
||||
}
|
||||
145
apps/sso-core/src/domain/addresses.service.ts
Normal file
145
apps/sso-core/src/domain/addresses.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
export const ADDRESS_LABELS = ['HOME', 'WORK', 'OTHER'] as const;
|
||||
export type AddressLabel = (typeof ADDRESS_LABELS)[number];
|
||||
|
||||
interface UpsertAddressCommand {
|
||||
userId: string;
|
||||
addressId?: string;
|
||||
label: string;
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
apartment?: string;
|
||||
comment?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
fullAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AddressesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private assertLabel(label: string): asserts label is AddressLabel {
|
||||
if (!ADDRESS_LABELS.includes(label as AddressLabel)) {
|
||||
throw new BadRequestException('Некорректный тип адреса');
|
||||
}
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const addresses = await this.prisma.address.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ label: 'asc' }, { updatedAt: 'desc' }]
|
||||
});
|
||||
return { addresses: addresses.map((address) => this.toResponse(address)) };
|
||||
}
|
||||
|
||||
async upsert(command: UpsertAddressCommand) {
|
||||
this.assertLabel(command.label);
|
||||
|
||||
const city = command.city.trim();
|
||||
const street = command.street.trim();
|
||||
const house = command.house.trim();
|
||||
if (!city || !street || !house) {
|
||||
throw new BadRequestException('Укажите город, улицу и дом');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
label: command.label,
|
||||
city,
|
||||
street,
|
||||
house,
|
||||
apartment: command.apartment?.trim() || null,
|
||||
comment: command.comment?.trim() || null,
|
||||
latitude: command.latitude ?? null,
|
||||
longitude: command.longitude ?? null,
|
||||
fullAddress: command.fullAddress?.trim() || this.buildFullAddress(city, street, house, command.apartment)
|
||||
};
|
||||
|
||||
if (command.addressId) {
|
||||
const existing = await this.prisma.address.findFirst({
|
||||
where: { id: command.addressId, userId: command.userId }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Адрес не найден');
|
||||
}
|
||||
const address = await this.prisma.address.update({
|
||||
where: { id: command.addressId },
|
||||
data: payload
|
||||
});
|
||||
return this.toResponse(address);
|
||||
}
|
||||
|
||||
if (command.label === 'HOME' || command.label === 'WORK') {
|
||||
const existing = await this.prisma.address.findFirst({
|
||||
where: { userId: command.userId, label: command.label }
|
||||
});
|
||||
if (existing) {
|
||||
const address = await this.prisma.address.update({
|
||||
where: { id: existing.id },
|
||||
data: payload
|
||||
});
|
||||
return this.toResponse(address);
|
||||
}
|
||||
}
|
||||
|
||||
const address = await this.prisma.address.create({
|
||||
data: {
|
||||
userId: command.userId,
|
||||
...payload
|
||||
}
|
||||
});
|
||||
return this.toResponse(address);
|
||||
}
|
||||
|
||||
async delete(addressId: string, userId: string) {
|
||||
const existing = await this.prisma.address.findFirst({
|
||||
where: { id: addressId, userId }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Адрес не найден');
|
||||
}
|
||||
await this.prisma.address.delete({ where: { id: addressId } });
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
private buildFullAddress(city: string, street: string, house: string, apartment?: string) {
|
||||
const parts = [`г. ${city}`, `${street}, ${house}`];
|
||||
if (apartment?.trim()) parts.push(`кв. ${apartment.trim()}`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
private toResponse(address: {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string;
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
apartment: string | null;
|
||||
comment: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
fullAddress: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: address.id,
|
||||
userId: address.userId,
|
||||
label: address.label,
|
||||
city: address.city,
|
||||
street: address.street,
|
||||
house: address.house,
|
||||
apartment: address.apartment ?? undefined,
|
||||
comment: address.comment ?? undefined,
|
||||
latitude: address.latitude ?? undefined,
|
||||
longitude: address.longitude ?? undefined,
|
||||
fullAddress: address.fullAddress ?? undefined,
|
||||
createdAt: address.createdAt.toISOString(),
|
||||
updatedAt: address.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
93
apps/sso-core/src/domain/admin-seed.service.ts
Normal file
93
apps/sso-core/src/domain/admin-seed.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
const PERMISSIONS = [
|
||||
{ slug: 'admin.access', name: 'Доступ к админ-панели', description: 'Позволяет открывать раздел администрирования' },
|
||||
{ slug: 'oauth.manage', name: 'Управление OAuth-приложениями', description: 'Создание и редактирование OAuth2-клиентов' },
|
||||
{ slug: 'users.manage', name: 'Управление пользователями', description: 'Редактирование, блокировка и сброс паролей' },
|
||||
{ slug: 'users.read', name: 'Просмотр пользователей', description: 'Чтение списка пользователей без изменений' },
|
||||
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
|
||||
{ slug: 'rbac.manage', name: 'Управление ролями', description: 'Назначение ролей и прав (только супер-админ)' },
|
||||
{ slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' }
|
||||
] as const;
|
||||
|
||||
const ROLES = [
|
||||
{
|
||||
slug: 'admin',
|
||||
name: 'Администратор',
|
||||
description: 'Полный доступ к админ-панели без назначения ролей',
|
||||
permissions: ['admin.access', 'oauth.manage', 'users.manage', 'settings.manage']
|
||||
},
|
||||
{
|
||||
slug: 'moderator',
|
||||
name: 'Модератор',
|
||||
description: 'Просмотр пользователей и базовый доступ к админке',
|
||||
permissions: ['admin.access', 'users.read']
|
||||
},
|
||||
{
|
||||
slug: 'manager',
|
||||
name: 'Менеджер',
|
||||
description: 'Управление OAuth-приложениями',
|
||||
permissions: ['admin.access', 'oauth.manage']
|
||||
}
|
||||
] as const;
|
||||
|
||||
const OAUTH_SCOPES = [
|
||||
{ slug: 'openid', name: 'OpenID', description: 'Базовая идентификация OpenID Connect' },
|
||||
{ slug: 'profile', name: 'Профиль', description: 'Имя, логин и аватар пользователя' },
|
||||
{ slug: 'email', name: 'Email', description: 'Основная и резервная почта' },
|
||||
{ slug: 'phone', name: 'Телефон', description: 'Основной и резервный телефон' },
|
||||
{ slug: 'address', name: 'Адреса', description: 'Адреса пользователя' },
|
||||
{ slug: 'documents', name: 'Документы', description: 'Документы пользователя' }
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class AdminSeedService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly rbac: RbacService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
for (const permission of PERMISSIONS) {
|
||||
await this.rbac.upsertPermission(permission);
|
||||
}
|
||||
|
||||
for (const scope of OAUTH_SCOPES) {
|
||||
await this.prisma.oAuthScope.upsert({
|
||||
where: { slug: scope.slug },
|
||||
create: scope,
|
||||
update: { name: scope.name, description: scope.description }
|
||||
});
|
||||
}
|
||||
|
||||
for (const role of ROLES) {
|
||||
const existing = await this.prisma.role.findUnique({ where: { slug: role.slug } });
|
||||
const roleRecord =
|
||||
existing ??
|
||||
(await this.prisma.role.create({
|
||||
data: {
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description
|
||||
}
|
||||
}));
|
||||
|
||||
await this.prisma.role.update({
|
||||
where: { id: roleRecord.id },
|
||||
data: { name: role.name, description: role.description }
|
||||
});
|
||||
|
||||
for (const slug of role.permissions) {
|
||||
const permission = await this.prisma.permission.findUnique({ where: { slug } });
|
||||
if (!permission) continue;
|
||||
await this.prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: roleRecord.id, permissionId: permission.id } },
|
||||
create: { roleId: roleRecord.id, permissionId: permission.id },
|
||||
update: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
apps/sso-core/src/domain/admin.service.ts
Normal file
136
apps/sso-core/src/domain/admin.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UserStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: AccessService
|
||||
) {}
|
||||
|
||||
async listUsers(search?: string) {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
{ displayName: { contains: search, mode: 'insensitive' } },
|
||||
{ username: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { userRoles: { include: { role: true } } }
|
||||
});
|
||||
|
||||
return users.map((user) => this.toAdminUser(user));
|
||||
}
|
||||
|
||||
async updateUserProfile(userId: string, data: { displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) {
|
||||
await this.ensureUserExists(userId);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
include: { userRoles: { include: { role: true } } }
|
||||
});
|
||||
return this.toAdminUser(user);
|
||||
}
|
||||
|
||||
async resetPassword(userId: string, newPassword: string) {
|
||||
if (newPassword.length < 8) {
|
||||
throw new BadRequestException('Пароль должен содержать минимум 8 символов');
|
||||
}
|
||||
|
||||
await this.ensureUserExists(userId);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: await bcrypt.hash(newPassword, 12) },
|
||||
include: { userRoles: { include: { role: true } } }
|
||||
});
|
||||
return this.toAdminUser(user);
|
||||
}
|
||||
|
||||
async suspendUser(userId: string) {
|
||||
await this.ensureUserExists(userId);
|
||||
await this.prisma.session.updateMany({ where: { userId }, data: { status: 'REVOKED' } });
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { status: UserStatus.SUSPENDED },
|
||||
include: { userRoles: { include: { role: true } } }
|
||||
});
|
||||
return this.toAdminUser(user);
|
||||
}
|
||||
|
||||
async setSuperAdmin(actorUserId: string, userId: string, isSuperAdmin: boolean) {
|
||||
await this.access.assertSuperAdmin(actorUserId);
|
||||
await this.ensureUserExists(userId);
|
||||
|
||||
const target = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!target) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
|
||||
if (target.isSuperAdmin && !isSuperAdmin) {
|
||||
const superAdmins = await this.prisma.user.count({ where: { isSuperAdmin: true, status: UserStatus.ACTIVE } });
|
||||
if (superAdmins <= 1) {
|
||||
throw new BadRequestException('Нельзя снять права у единственного супер-администратора');
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isSuperAdmin },
|
||||
include: { userRoles: { include: { role: true } } }
|
||||
});
|
||||
return this.toAdminUser(user);
|
||||
}
|
||||
|
||||
async deleteUser(userId: string) {
|
||||
await this.ensureUserExists(userId);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { status: UserStatus.DELETED, deletedAt: new Date() },
|
||||
include: { userRoles: { include: { role: true } } }
|
||||
});
|
||||
return this.toAdminUser(user);
|
||||
}
|
||||
|
||||
private toAdminUser(user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
backupEmail: string | null;
|
||||
backupPhone: string | null;
|
||||
displayName: string;
|
||||
username: string | null;
|
||||
isSuperAdmin: boolean;
|
||||
status: UserStatus;
|
||||
createdAt: Date;
|
||||
userRoles?: { role: { slug: string } }[];
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email ?? undefined,
|
||||
phone: user.phone ?? undefined,
|
||||
backupEmail: user.backupEmail ?? undefined,
|
||||
backupPhone: user.backupPhone ?? undefined,
|
||||
displayName: user.displayName,
|
||||
username: user.username ?? undefined,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
status: user.status,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
roles: user.userRoles?.map((link) => link.role.slug) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureUserExists(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
}
|
||||
}
|
||||
37
apps/sso-core/src/domain/advanced-auth.service.ts
Normal file
37
apps/sso-core/src/domain/advanced-auth.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AdvancedAuthService {
|
||||
createWebAuthnRegistrationChallenge(userId: string) {
|
||||
return this.challenge(`webauthn-register:${userId}`);
|
||||
}
|
||||
|
||||
createWebAuthnLoginChallenge(userId: string) {
|
||||
return this.challenge(`webauthn-login:${userId}`);
|
||||
}
|
||||
|
||||
createQrSession() {
|
||||
return {
|
||||
sessionId: randomUUID(),
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date(Date.now() + 2 * 60_000).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
pollQrSession(sessionId: string) {
|
||||
return {
|
||||
sessionId,
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date(Date.now() + 2 * 60_000).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private challenge(prefix: string) {
|
||||
return {
|
||||
challengeId: randomUUID(),
|
||||
challenge: `${prefix}:${randomBytes(32).toString('base64url')}`,
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
787
apps/sso-core/src/domain/auth-grpc.controller.ts
Normal file
787
apps/sso-core/src/domain/auth-grpc.controller.ts
Normal file
@@ -0,0 +1,787 @@
|
||||
import { Controller, UseFilters } from '@nestjs/common';
|
||||
import { GrpcMethod } from '@nestjs/microservices';
|
||||
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginCommand, RegisterCommand, VerifyPinCommand } from './dto';
|
||||
import { PinService } from './pin.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { SecurityService } from './security.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { UserStatus } from '../generated/prisma/client';
|
||||
import { ProfileService } from './profile.service';
|
||||
import { DocumentsService } from './documents.service';
|
||||
import { AddressesService } from './addresses.service';
|
||||
import { OAuthCoreService } from './oauth-core.service';
|
||||
import { OtpService } from './otp.service';
|
||||
import { AdvancedAuthService } from './advanced-auth.service';
|
||||
import { FamilyService } from './family.service';
|
||||
import { MediaService } from './media.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Controller()
|
||||
@UseFilters(new GrpcExceptionFilter())
|
||||
export class AuthGrpcController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly pin: PinService,
|
||||
private readonly session: SessionService,
|
||||
private readonly admin: AdminService,
|
||||
private readonly rbac: RbacService,
|
||||
private readonly security: SecurityService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly profile: ProfileService,
|
||||
private readonly documents: DocumentsService,
|
||||
private readonly addresses: AddressesService,
|
||||
private readonly oauthCore: OAuthCoreService,
|
||||
private readonly otp: OtpService,
|
||||
private readonly advancedAuth: AdvancedAuthService,
|
||||
private readonly family: FamilyService,
|
||||
private readonly media: MediaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly chat: ChatService
|
||||
) {}
|
||||
|
||||
@GrpcMethod('AuthService', 'Register')
|
||||
register(command: RegisterCommand) {
|
||||
return this.auth.register(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'Login')
|
||||
login(command: LoginCommand) {
|
||||
return this.auth.login(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'Identify')
|
||||
identify(command: { login: string }) {
|
||||
return this.auth.identify(command.login);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'SendOtp')
|
||||
sendPasswordlessOtp(command: { recipient: string; channel?: string }) {
|
||||
return this.auth.sendOtp(command.recipient, command.channel);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'VerifyOtp')
|
||||
verifyPasswordlessOtp(command: LoginCommand & { recipient: string; code: string }) {
|
||||
return this.auth.verifyOtp(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'LoginWithPassword')
|
||||
loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) {
|
||||
return this.auth.loginWithPassword(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'LoginWithLdap')
|
||||
loginWithLdap(command: {
|
||||
username: string;
|
||||
password: string;
|
||||
fingerprint: string;
|
||||
deviceName?: string;
|
||||
deviceType?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}) {
|
||||
return this.auth.loginWithLdap(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'VerifyPin')
|
||||
verifyPin(command: VerifyPinCommand) {
|
||||
return this.pin.verifyPin(command.sessionId, command.pin);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'GetMe')
|
||||
getMe(command: { userId: string }) {
|
||||
return this.auth.getMe(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'RefreshSession')
|
||||
refreshSession(command: { refreshToken: string; sessionId?: string }) {
|
||||
return this.session.refreshSession(command.refreshToken, command.sessionId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AuthService', 'ValidateSession')
|
||||
validateSession(command: { userId: string; sessionId: string; touchActivity?: boolean }) {
|
||||
return this.session.validateSession(command.userId, command.sessionId, command.touchActivity ?? false);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdminService', 'ListUsers')
|
||||
async listUsers(command: { search?: string }) {
|
||||
const users = await this.admin.listUsers(command.search);
|
||||
return { users };
|
||||
}
|
||||
|
||||
@GrpcMethod('AdminService', 'SuspendUser')
|
||||
suspendUser(command: { userId: string }) {
|
||||
return this.admin.suspendUser(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdminService', 'UpdateUserProfile')
|
||||
updateUserProfile(command: { userId: string; displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) {
|
||||
const { userId, ...data } = command;
|
||||
return this.admin.updateUserProfile(userId, data);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdminService', 'ResetPassword')
|
||||
resetPassword(command: { userId: string; newPassword: string }) {
|
||||
return this.admin.resetPassword(command.userId, command.newPassword);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdminService', 'SetSuperAdmin')
|
||||
setSuperAdmin(command: { actorUserId: string; userId: string; isSuperAdmin: boolean }) {
|
||||
return this.admin.setSuperAdmin(command.actorUserId, command.userId, command.isSuperAdmin);
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'ListRoles')
|
||||
async listRoles() {
|
||||
const roles = await this.rbac.listRoles();
|
||||
return {
|
||||
roles: roles.map((role) => ({
|
||||
id: role.id,
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description ?? undefined,
|
||||
permissions: role.permissions.map((link) => ({
|
||||
id: link.permission.id,
|
||||
slug: link.permission.slug,
|
||||
name: link.permission.name,
|
||||
description: link.permission.description ?? undefined
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'ListOAuthClients')
|
||||
async listOAuthClients() {
|
||||
const clients = await this.rbac.listOAuthClients();
|
||||
return {
|
||||
clients: clients.map((client) => ({
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
clientId: client.clientId,
|
||||
redirectUris: client.redirectUris,
|
||||
scopes: client.scopes.map((link) => link.scope.slug),
|
||||
isActive: client.isActive,
|
||||
type: client.type
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'ListPermissions')
|
||||
async listPermissions() {
|
||||
const permissions = await this.rbac.listPermissions();
|
||||
return {
|
||||
permissions: permissions.map((permission) => ({
|
||||
id: permission.id,
|
||||
slug: permission.slug,
|
||||
name: permission.name,
|
||||
description: permission.description ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'ListOAuthScopes')
|
||||
async listOAuthScopes() {
|
||||
const scopes = await this.rbac.listOAuthScopes();
|
||||
return {
|
||||
scopes: scopes.map((scope) => ({
|
||||
id: scope.id,
|
||||
slug: scope.slug,
|
||||
name: scope.name,
|
||||
description: scope.description ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'CreateRole')
|
||||
async createRole(command: { actorUserId: string; slug: string; name: string; description?: string; permissionSlugs?: string[] }) {
|
||||
const role = await this.rbac.createRole(command.actorUserId, {
|
||||
slug: command.slug,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
permissionSlugs: command.permissionSlugs
|
||||
});
|
||||
return {
|
||||
id: role.id,
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description ?? undefined,
|
||||
permissions: role.permissions.map((link) => ({
|
||||
id: link.permission.id,
|
||||
slug: link.permission.slug,
|
||||
name: link.permission.name,
|
||||
description: link.permission.description ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'AssignUserRole')
|
||||
async assignUserRole(command: { actorUserId: string; userId: string; roleSlug: string }) {
|
||||
const roles = await this.rbac.assignRole(command.actorUserId, command.userId, command.roleSlug);
|
||||
return { userId: command.userId, roles };
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'RemoveUserRole')
|
||||
async removeUserRole(command: { actorUserId: string; userId: string; roleSlug: string }) {
|
||||
await this.rbac.removeRole(command.actorUserId, command.userId, command.roleSlug);
|
||||
return {};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'CreateOAuthClient')
|
||||
async createOAuthClient(command: { actorUserId: string; name: string; redirectUris: string[]; scopes: string[]; type?: string }) {
|
||||
const { client, clientSecret } = await this.rbac.createOAuthClient(command.actorUserId, {
|
||||
name: command.name,
|
||||
redirectUris: command.redirectUris,
|
||||
scopes: command.scopes,
|
||||
type: command.type as never
|
||||
});
|
||||
return {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
clientId: client.clientId,
|
||||
redirectUris: client.redirectUris,
|
||||
scopes: client.scopes.map((link) => link.scope.slug),
|
||||
isActive: client.isActive,
|
||||
type: client.type,
|
||||
clientSecret: clientSecret ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'UpdateOAuthClient')
|
||||
async updateOAuthClient(command: {
|
||||
actorUserId: string;
|
||||
clientId: string;
|
||||
name?: string;
|
||||
redirectUris?: string[];
|
||||
scopes?: string[];
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const { client } = await this.rbac.updateOAuthClient(command.actorUserId, command.clientId, {
|
||||
name: command.name,
|
||||
redirectUris: command.redirectUris,
|
||||
scopes: command.scopes,
|
||||
isActive: command.isActive
|
||||
});
|
||||
return {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
clientId: client.clientId,
|
||||
redirectUris: client.redirectUris,
|
||||
scopes: client.scopes.map((link) => link.scope.slug),
|
||||
isActive: client.isActive,
|
||||
type: client.type
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('RbacService', 'RotateOAuthSecret')
|
||||
async rotateOAuthSecret(command: { actorUserId: string; clientId: string }) {
|
||||
return this.rbac.rotateOAuthSecret(command.actorUserId, command.clientId);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'ListActiveDevices')
|
||||
async listActiveDevices(command: { userId: string }) {
|
||||
const devices = await this.security.listActiveDevices(command.userId);
|
||||
return {
|
||||
devices: devices.map((device) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
type: device.type,
|
||||
lastIp: device.lastIp,
|
||||
lastSeenAt: device.lastSeenAt.toISOString(),
|
||||
trusted: device.trusted
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'ListSignInHistory')
|
||||
async listSignInHistory(command: { userId: string }) {
|
||||
const events = await this.security.listSignInHistory(command.userId);
|
||||
return {
|
||||
events: events.map((event) => ({
|
||||
id: event.id,
|
||||
success: event.success,
|
||||
reason: event.reason,
|
||||
ipAddress: event.ipAddress,
|
||||
userAgent: event.userAgent,
|
||||
createdAt: event.createdAt.toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'ListActiveSessions')
|
||||
async listActiveSessions(command: { userId: string }) {
|
||||
const sessions = await this.security.listActiveSessions(command.userId);
|
||||
return {
|
||||
sessions: sessions.map((session) => this.toSessionResponse(session))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'LockSession')
|
||||
async lockSession(command: { userId: string; sessionId: string }) {
|
||||
return this.toSessionResponse(await this.security.lockSession(command.userId, command.sessionId));
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'RevokeSession')
|
||||
async revokeSession(command: { userId: string; sessionId: string }) {
|
||||
return this.toSessionResponse(await this.security.revokeSession(command.userId, command.sessionId));
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'RevokeDevice')
|
||||
async revokeDevice(command: { userId: string; deviceId: string }) {
|
||||
const result = await this.security.revokeDevice(command.userId, command.deviceId);
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'RevokeAllSessions')
|
||||
async revokeAllSessions(command: { userId: string }) {
|
||||
const result = await this.security.revokeAllSessions(command.userId);
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'SetupPin')
|
||||
setupPin(command: { userId: string; pin: string }) {
|
||||
return this.pin.setupPin(command.userId, command.pin);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'UpdatePin')
|
||||
updatePin(command: { userId: string; pin: string }) {
|
||||
return this.pin.updatePin(command.userId, command.pin);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'RequestPinDeletion')
|
||||
requestPinDeletion(command: { userId: string; pin: string }) {
|
||||
return this.pin.requestPinDeletion(command.userId, command.pin);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'CancelPinDeletion')
|
||||
cancelPinDeletion(command: { userId: string }) {
|
||||
return this.pin.cancelPinDeletion(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'VerifyPinCode')
|
||||
verifyPinCode(command: { sessionId: string; pin: string }) {
|
||||
return this.pin.verifyPin(command.sessionId, command.pin);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'ConnectLinkedAccount')
|
||||
async connectLinkedAccount(command: { userId: string; providerName: string; providerId: string; email?: string; displayName?: string; avatarUrl?: string }) {
|
||||
return this.toLinkedAccountResponse(await this.security.connectSocialAccount(command.userId, command));
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'DisconnectLinkedAccount')
|
||||
disconnectLinkedAccount(command: { userId: string; providerName: string }) {
|
||||
return this.security.disconnectSocialAccount(command.userId, command.providerName);
|
||||
}
|
||||
|
||||
@GrpcMethod('SecurityService', 'ListLinkedAccounts')
|
||||
async listLinkedAccounts(command: { userId: string }) {
|
||||
const accounts = await this.security.listLinkedAccounts(command.userId);
|
||||
return { accounts: accounts.map((account) => this.toLinkedAccountResponse(account)) };
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'ListSettings')
|
||||
async listSettings() {
|
||||
const settings = await this.settings.list();
|
||||
return {
|
||||
settings: settings.map((setting) => ({
|
||||
id: setting.id,
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
description: setting.description ?? undefined,
|
||||
isSecret: setting.isSecret
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'ListPublicSettings')
|
||||
async listPublicSettings() {
|
||||
const settings = await this.settings.listPublic();
|
||||
return {
|
||||
settings: settings.map((setting) => ({
|
||||
key: setting.key,
|
||||
value: setting.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'GetSetting')
|
||||
getSetting(command: { key: string }) {
|
||||
return this.settings.get(command.key);
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'UpsertSetting')
|
||||
upsertSetting(command: { key: string; value: string; description?: string; isSecret?: boolean }) {
|
||||
return this.settings.upsert(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'DeleteSetting')
|
||||
deleteSetting(command: { key: string }) {
|
||||
return this.settings.delete(command.key);
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'ListSocialProviders')
|
||||
async listSocialProviders() {
|
||||
const providers = await this.settings.listSocialProviders();
|
||||
return { providers: providers.map((provider) => this.toSocialProviderResponse(provider)) };
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'UpsertSocialProvider')
|
||||
async upsertSocialProvider(command: { providerName: string; clientId: string; clientSecret?: string; isEnabled: boolean }) {
|
||||
return this.toSocialProviderResponse(await this.settings.upsertSocialProvider(command));
|
||||
}
|
||||
|
||||
@GrpcMethod('SettingsService', 'DeleteSocialProvider')
|
||||
deleteSocialProvider(command: { providerName: string }) {
|
||||
return this.settings.deleteSocialProvider(command.providerName);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'GetProfile')
|
||||
getProfile(command: { userId: string }) {
|
||||
return this.profile.getProfile(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'UpdateAvatar')
|
||||
updateAvatar(command: { userId: string; avatarUrl?: string; avatarStorageKey?: string }) {
|
||||
return this.profile.updateAvatar(command.userId, command.avatarUrl, command.avatarStorageKey);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'UpdateProfile')
|
||||
updateProfile(command: { userId: string; firstName?: string; lastName?: string; bio?: string; age?: number; gender?: string }) {
|
||||
return this.profile.updateProfile(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'UpdateContacts')
|
||||
updateContacts(command: { userId: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string }) {
|
||||
return this.profile.updateContacts(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'SetPassword')
|
||||
setPassword(command: { userId: string; password: string }) {
|
||||
return this.profile.setPassword(command.userId, command.password);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'SoftDeleteProfile')
|
||||
softDeleteProfile(command: { userId: string }) {
|
||||
return this.profile.softDeleteProfile(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('DocumentsService', 'CreateDocument')
|
||||
createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
|
||||
return this.documents.create(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('DocumentsService', 'ListDocuments')
|
||||
listDocuments(command: { userId: string }) {
|
||||
return this.documents.list(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('DocumentsService', 'GetDocument')
|
||||
getDocument(command: { documentId: string; userId: string }) {
|
||||
return this.documents.get(command.documentId, command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('DocumentsService', 'UpdateDocument')
|
||||
updateDocument(command: { documentId: string; userId: string; number?: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
|
||||
return this.documents.update(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('DocumentsService', 'DeleteDocument')
|
||||
deleteDocument(command: { documentId: string; userId: string }) {
|
||||
return this.documents.delete(command.documentId, command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AddressesService', 'ListAddresses')
|
||||
listAddresses(command: { userId: string }) {
|
||||
return this.addresses.list(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AddressesService', 'UpsertAddress')
|
||||
upsertAddress(command: {
|
||||
userId: string;
|
||||
addressId?: string;
|
||||
label: string;
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
apartment?: string;
|
||||
comment?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
fullAddress?: string;
|
||||
}) {
|
||||
return this.addresses.upsert(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AddressesService', 'DeleteAddress')
|
||||
deleteAddress(command: { addressId: string; userId: string }) {
|
||||
return this.addresses.delete(command.addressId, command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateAvatarUploadUrl')
|
||||
createAvatarUploadUrl(command: { userId: string; contentType: string }) {
|
||||
return this.media.createAvatarUploadUrl(command.userId, command.contentType);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'ConfirmAvatar')
|
||||
confirmAvatar(command: { userId: string; storageKey: string }) {
|
||||
return this.media.confirmAvatar(command.userId, command.storageKey);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetAvatarAccessUrl')
|
||||
getAvatarAccessUrl(command: { requesterId: string; targetUserId: string }) {
|
||||
return this.media.getAvatarAccessUrl(command.requesterId, command.targetUserId);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl')
|
||||
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string }) {
|
||||
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl')
|
||||
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string }) {
|
||||
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'ResolveStreamToken')
|
||||
resolveStreamToken(command: { token: string; requesterId?: string }) {
|
||||
return this.media.resolveStreamToken(command.token, command.requesterId);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateFamilyAvatarUploadUrl')
|
||||
createFamilyAvatarUploadUrl(command: { requesterId: string; groupId: string; contentType: string }) {
|
||||
return this.media.createFamilyAvatarUploadUrl(command.requesterId, command.groupId, command.contentType);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'ConfirmFamilyAvatar')
|
||||
confirmFamilyAvatar(command: { requesterId: string; groupId: string; storageKey: string }) {
|
||||
return this.media.confirmFamilyAvatar(command.requesterId, command.groupId, command.storageKey);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetFamilyAvatarAccessUrl')
|
||||
getFamilyAvatarAccessUrl(command: { requesterId: string; groupId: string }) {
|
||||
return this.media.getFamilyAvatarAccessUrl(command.requesterId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateChatMediaUploadUrl')
|
||||
createChatMediaUploadUrl(command: { requesterId: string; roomId: string; contentType: string; fileName?: string }) {
|
||||
return this.media.createChatMediaUploadUrl(command.requesterId, command.roomId, command.contentType, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetChatMediaAccessUrl')
|
||||
getChatMediaAccessUrl(command: { requesterId: string; roomId: string; storageKey: string; fileName?: string }) {
|
||||
return this.media.getChatMediaAccessUrl(command.requesterId, command.roomId, command.storageKey, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('OAuthCoreService', 'Authorize')
|
||||
authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
|
||||
return this.oauthCore.authorize(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('OAuthCoreService', 'Token')
|
||||
token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) {
|
||||
return this.oauthCore.token(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('OAuthCoreService', 'UserInfo')
|
||||
userInfo(command: { accessToken: string }) {
|
||||
return this.oauthCore.userInfo(command.accessToken);
|
||||
}
|
||||
|
||||
@GrpcMethod('OtpService', 'SendOtp')
|
||||
sendOtp(command: { target: string; channel: string; purpose: string; userId?: string }) {
|
||||
return this.otp.send(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('OtpService', 'VerifyOtp')
|
||||
verifyOtp(command: { target: string; code: string; purpose: string }) {
|
||||
return this.otp.verify(command);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdvancedAuthService', 'CreateWebAuthnRegistrationChallenge')
|
||||
createWebAuthnRegistrationChallenge(command: { userId: string }) {
|
||||
return this.advancedAuth.createWebAuthnRegistrationChallenge(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdvancedAuthService', 'CreateWebAuthnLoginChallenge')
|
||||
createWebAuthnLoginChallenge(command: { userId: string }) {
|
||||
return this.advancedAuth.createWebAuthnLoginChallenge(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('AdvancedAuthService', 'CreateQrSession')
|
||||
createQrSession(command: { deviceName: string }) {
|
||||
return this.advancedAuth.createQrSession();
|
||||
}
|
||||
|
||||
@GrpcMethod('AdvancedAuthService', 'PollQrSession')
|
||||
pollQrSession(command: { sessionId: string }) {
|
||||
return this.advancedAuth.pollQrSession(command.sessionId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'CreateFamilyGroup')
|
||||
createFamilyGroup(command: { ownerId: string; name: string }) {
|
||||
return this.family.create(command.ownerId, command.name);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'ListFamilyGroups')
|
||||
listFamilyGroups(command: { userId: string }) {
|
||||
return this.family.list(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'GetFamilyGroup')
|
||||
getFamilyGroup(command: { requesterId: string; groupId: string }) {
|
||||
return this.family.get(command.requesterId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'UpdateFamilyGroup')
|
||||
updateFamilyGroup(command: { requesterId: string; groupId: string; name: string }) {
|
||||
return this.family.update(command.requesterId, command.groupId, command.name);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'DeleteFamilyGroup')
|
||||
deleteFamilyGroup(command: { requesterId: string; groupId: string }) {
|
||||
return this.family.delete(command.requesterId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'AddFamilyMember')
|
||||
addFamilyMember(command: { groupId: string; userId: string; role: string }) {
|
||||
return this.family.addMember(command.groupId, command.userId, command.role);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'RemoveFamilyMember')
|
||||
removeFamilyMember(command: { requesterId: string; memberId: string }) {
|
||||
return this.family.removeMember(command.requesterId, command.memberId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'SendFamilyInvite')
|
||||
sendFamilyInvite(command: { requesterId: string; groupId: string; target: string }) {
|
||||
return this.family.sendInvite(command.requesterId, command.groupId, command.target);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'RespondFamilyInvite')
|
||||
respondFamilyInvite(command: { userId: string; inviteId: string; accept: boolean }) {
|
||||
return this.family.respondInvite(command.userId, command.inviteId, command.accept);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'ListFamilyInvites')
|
||||
listFamilyInvites(command: { userId: string; groupId?: string }) {
|
||||
return this.family.listInvites(command.userId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'ListNotifications')
|
||||
listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) {
|
||||
return this.notifications.list(command.userId, command.limit, command.unreadOnly);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'GetUnreadCount')
|
||||
getUnreadCount(command: { userId: string }) {
|
||||
return this.notifications.unreadCount(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'MarkNotificationRead')
|
||||
markNotificationRead(command: { userId: string; notificationId: string }) {
|
||||
return this.notifications.markRead(command.userId, command.notificationId);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'MarkAllNotificationsRead')
|
||||
markAllNotificationsRead(command: { userId: string }) {
|
||||
return this.notifications.markAllRead(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'DeleteNotification')
|
||||
deleteNotification(command: { userId: string; notificationId: string }) {
|
||||
return this.notifications.delete(command.userId, command.notificationId);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'DeleteAllNotifications')
|
||||
deleteAllNotifications(command: { userId: string }) {
|
||||
return this.notifications.deleteAll(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'ListRooms')
|
||||
listChatRooms(command: { userId: string; groupId: string }) {
|
||||
return this.chat.listRooms(command.userId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'CreateRoom')
|
||||
createChatRoom(command: { userId: string; groupId: string; name: string; memberUserIds?: string[] }) {
|
||||
return this.chat.createRoom(command.userId, command.groupId, command.name, command.memberUserIds ?? []);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'UpdateRoomSettings')
|
||||
updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean }) {
|
||||
return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'ListMessages')
|
||||
listChatMessages(command: { userId: string; roomId: string; beforeMessageId?: string; limit?: number }) {
|
||||
return this.chat.listMessages(command.userId, command.roomId, command.beforeMessageId, command.limit);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'SendMessage')
|
||||
sendChatMessage(command: {
|
||||
userId: string;
|
||||
roomId: string;
|
||||
type: string;
|
||||
content?: string;
|
||||
replyToId?: string;
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
metadataJson?: string;
|
||||
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
|
||||
}) {
|
||||
return this.chat.sendMessage(
|
||||
command.userId,
|
||||
command.roomId,
|
||||
command.type,
|
||||
command.content,
|
||||
command.replyToId,
|
||||
command.storageKey,
|
||||
command.mimeType,
|
||||
command.metadataJson,
|
||||
command.poll
|
||||
);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'VotePoll')
|
||||
voteChatPoll(command: { userId: string; messageId: string; optionIds?: string[] }) {
|
||||
return this.chat.votePoll(command.userId, command.messageId, command.optionIds ?? []);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'SetRoomNotificationsMuted')
|
||||
setRoomNotificationsMuted(command: { userId: string; roomId: string; muted: boolean }) {
|
||||
return this.chat.setRoomNotificationsMuted(command.userId, command.roomId, command.muted);
|
||||
}
|
||||
|
||||
private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) {
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
deviceId: session.deviceId,
|
||||
pinVerified: session.pinVerified,
|
||||
status: session.status,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private toLinkedAccountResponse(account: { id: string; userId: string; providerName: string; providerId: string; email: string | null; displayName: string | null; avatarUrl: string | null; createdAt: Date; updatedAt: Date }) {
|
||||
return {
|
||||
...account,
|
||||
createdAt: account.createdAt.toISOString(),
|
||||
updatedAt: account.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private toSocialProviderResponse(provider: { id: string; providerName: string; clientId: string; clientSecret: string | null; isEnabled: boolean; createdAt: Date; updatedAt: Date }) {
|
||||
return {
|
||||
...provider,
|
||||
createdAt: provider.createdAt.toISOString(),
|
||||
updatedAt: provider.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
519
apps/sso-core/src/domain/auth.service.ts
Normal file
519
apps/sso-core/src/domain/auth.service.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomBytes, randomInt } from 'node:crypto';
|
||||
import { DeviceType, Prisma, SessionStatus, User, UserStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { RedisService } from '../infra/redis.service';
|
||||
import { LdapClientService, LdapConnectionConfig } from '../infra/ldap-client.service';
|
||||
import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, validateLdapConfig } from '../infra/ldap-config.util';
|
||||
import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
|
||||
import { AccessService } from './access.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly access: AccessService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly ldapClient: LdapClientService
|
||||
) {}
|
||||
|
||||
async register(command: RegisterCommand): Promise<PublicUser> {
|
||||
if (!command.email && !command.phone) {
|
||||
throw new BadRequestException('Укажите почту или телефон');
|
||||
}
|
||||
|
||||
const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null;
|
||||
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
||||
|
||||
if (!lockAcquired) {
|
||||
throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const usersCount = await tx.user.count({ where: { deletedAt: null } });
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: command.email,
|
||||
phone: command.phone,
|
||||
passwordHash,
|
||||
displayName: command.displayName,
|
||||
username: command.username,
|
||||
isSuperAdmin: usersCount === 0
|
||||
}
|
||||
});
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
|
||||
);
|
||||
|
||||
return await this.toPublicUser(user);
|
||||
} catch (error) {
|
||||
if (this.isUniqueError(error)) {
|
||||
throw new BadRequestException('Пользователь с такими данными уже существует');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
|
||||
}
|
||||
}
|
||||
|
||||
async login(command: LoginCommand): Promise<AuthTokens> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: command.login }, { phone: command.login }, { username: command.login }],
|
||||
deletedAt: null,
|
||||
status: UserStatus.ACTIVE
|
||||
},
|
||||
include: { pinCode: true }
|
||||
});
|
||||
|
||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new UnauthorizedException('Для этого аккаунта используйте вход по коду');
|
||||
}
|
||||
|
||||
const passwordMatches = await bcrypt.compare(command.password, user.passwordHash);
|
||||
if (!passwordMatches) {
|
||||
await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
|
||||
throw new UnauthorizedException('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
const device = await this.upsertDevice(user.id, command);
|
||||
|
||||
const auth = await this.createAuthTokens(user, device.id, command);
|
||||
await this.writeSignInEvent(user.id, true, command, device.id);
|
||||
return auth;
|
||||
}
|
||||
|
||||
async identify(login: string) {
|
||||
const user = await this.findUserByRecipient(login);
|
||||
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
|
||||
|
||||
if (user?.passwordHash) {
|
||||
methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
|
||||
}
|
||||
|
||||
if (user) {
|
||||
const channels: Array<{ channel: string; value: string | null }> = [
|
||||
{ channel: 'email', value: user.email },
|
||||
{ channel: 'phone', value: user.phone },
|
||||
{ channel: 'backupEmail', value: user.backupEmail },
|
||||
{ channel: 'backupPhone', value: user.backupPhone }
|
||||
];
|
||||
for (const item of channels) {
|
||||
if (item.value && item.value !== login) {
|
||||
methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exists: Boolean(user),
|
||||
hasPassword: Boolean(user?.passwordHash),
|
||||
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
|
||||
methods
|
||||
};
|
||||
}
|
||||
|
||||
async sendOtp(recipient: string, channel?: string) {
|
||||
const existingUser = await this.findUserByRecipient(recipient);
|
||||
const target = this.resolveOtpTarget(recipient, channel, existingUser);
|
||||
const code = String(randomInt(100000, 999999));
|
||||
const expiresAt = new Date(Date.now() + 5 * 60_000);
|
||||
await this.prisma.authCode.create({
|
||||
data: {
|
||||
userId: existingUser?.id,
|
||||
target,
|
||||
channel: target.includes('@') ? 'email' : 'sms',
|
||||
purpose: 'passwordless-login',
|
||||
codeHash: await bcrypt.hash(code, 10),
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
console.log('[OTP MOCK] Code for', target, 'is:', code);
|
||||
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
|
||||
}
|
||||
|
||||
async verifyOtp(command: LoginCommand & { recipient: string; code: string }) {
|
||||
const existingUser = await this.findUserByRecipient(command.recipient);
|
||||
const authCode = await this.prisma.authCode.findFirst({
|
||||
where: {
|
||||
purpose: 'passwordless-login',
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
OR: [{ target: command.recipient }, ...(existingUser ? [{ userId: existingUser.id }] : [])]
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (!authCode) throw new UnauthorizedException('Код входа не найден или истек');
|
||||
const matches = await bcrypt.compare(command.code, authCode.codeHash);
|
||||
if (!matches) throw new UnauthorizedException('Неверный код входа');
|
||||
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
|
||||
|
||||
const user = await this.findOrCreatePasswordlessUser(command.recipient);
|
||||
|
||||
const device = await this.upsertDevice(user.id, command);
|
||||
const auth = await this.createAuthTokens(user, device.id, command);
|
||||
await this.writeSignInEvent(user.id, true, { ...command, login: command.recipient, password: '' }, device.id);
|
||||
return { requiresPassword: false, tempAuthToken: undefined, auth };
|
||||
}
|
||||
|
||||
private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
|
||||
if (!channel || channel === 'primary' || !user) return recipient;
|
||||
const map: Record<string, string | null> = {
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
backupEmail: user.backupEmail,
|
||||
backupPhone: user.backupPhone
|
||||
};
|
||||
return map[channel] ?? recipient;
|
||||
}
|
||||
|
||||
private maskTarget(value: string) {
|
||||
if (value.includes('@')) {
|
||||
const [name, domain] = value.split('@');
|
||||
const visible = name.slice(0, 1);
|
||||
return `${visible}${'*'.repeat(Math.max(name.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const tail = value.slice(-2);
|
||||
const head = value.slice(0, value.startsWith('+') ? 2 : 1);
|
||||
return `${head}${'*'.repeat(Math.max(value.length - head.length - 2, 2))}${tail}`;
|
||||
}
|
||||
|
||||
async loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) {
|
||||
const user = command.tempAuthToken
|
||||
? await this.findUserByTempPasswordToken(command.tempAuthToken)
|
||||
: await this.findUserByRecipient(command.login);
|
||||
if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
const matches = await bcrypt.compare(command.password, user.passwordHash);
|
||||
if (!matches) {
|
||||
await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
|
||||
throw new UnauthorizedException('Неверный пароль');
|
||||
}
|
||||
const device = await this.upsertDevice(user.id, command);
|
||||
const auth = await this.createAuthTokens(user, device.id, command);
|
||||
await this.writeSignInEvent(user.id, true, command, device.id);
|
||||
return auth;
|
||||
}
|
||||
|
||||
async loginWithLdap(command: {
|
||||
username: string;
|
||||
password: string;
|
||||
fingerprint: string;
|
||||
deviceName?: string;
|
||||
deviceType?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}): Promise<AuthTokens> {
|
||||
const enabled = await this.settings.getBoolean('LDAP_ENABLED', false);
|
||||
if (!enabled) {
|
||||
throw new UnauthorizedException('LDAP-аутентификация отключена');
|
||||
}
|
||||
|
||||
const ldapConfig = await this.getLdapConfig();
|
||||
validateLdapConfig(ldapConfig);
|
||||
let ldapResult;
|
||||
try {
|
||||
ldapResult = await this.ldapClient.authenticate(ldapConfig, command.username, command.password);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException(error instanceof Error ? error.message : 'Неверный логин или пароль LDAP');
|
||||
}
|
||||
|
||||
const user = await this.findOrCreateLdapUser(ldapResult);
|
||||
const device = await this.upsertDevice(user.id, command);
|
||||
const auth = await this.createAuthTokens(user, device.id, command);
|
||||
await this.writeSignInEvent(
|
||||
user.id,
|
||||
true,
|
||||
{ login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent },
|
||||
device.id
|
||||
);
|
||||
return auth;
|
||||
}
|
||||
|
||||
private async getLdapConfig(): Promise<LdapConnectionConfig> {
|
||||
const useLdaps = await this.settings.getBoolean('LDAP_USE_LDAPS', false);
|
||||
const rawPort = await this.settings.getNumber('LDAP_PORT', useLdaps ? 636 : 389);
|
||||
const domain = await this.settings.getValue('LDAP_DOMAIN', '');
|
||||
const bindUsername = await this.settings.getValue('LDAP_BIND_USERNAME', '');
|
||||
const bindDnRaw = await this.settings.getValue('LDAP_BIND_DN', '');
|
||||
const bindDn = resolveLdapBindIdentity(bindUsername, bindDnRaw, domain);
|
||||
return {
|
||||
host: await this.settings.getValue('LDAP_HOST', ''),
|
||||
port: resolveLdapPort(rawPort, useLdaps),
|
||||
useLdaps,
|
||||
skipTlsVerify: await this.settings.getBoolean('LDAP_SKIP_TLS_VERIFY', false),
|
||||
domain,
|
||||
baseDn: await this.settings.getValue('LDAP_BASE_DN', ''),
|
||||
bindDn,
|
||||
bindPassword: await this.settings.getValue('LDAP_BIND_PASSWORD', ''),
|
||||
userFilter: normalizeLdapUserFilter(
|
||||
await this.settings.getValue(
|
||||
'LDAP_USER_FILTER',
|
||||
'(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))'
|
||||
)
|
||||
),
|
||||
usernameAttr: await this.settings.getValue('LDAP_USERNAME_ATTR', 'sAMAccountName'),
|
||||
emailAttr: await this.settings.getValue('LDAP_EMAIL_ATTR', 'mail'),
|
||||
displayNameAttr: await this.settings.getValue('LDAP_DISPLAY_NAME_ATTR', 'displayName')
|
||||
};
|
||||
}
|
||||
|
||||
private async findOrCreateLdapUser(ldapResult: { dn: string; username: string; email?: string; displayName: string }) {
|
||||
const linked = await this.prisma.linkedAccount.findUnique({
|
||||
where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } },
|
||||
include: { user: { include: { pinCode: true } } }
|
||||
});
|
||||
if (linked?.user && !linked.user.deletedAt && linked.user.status === UserStatus.ACTIVE) {
|
||||
return linked.user;
|
||||
}
|
||||
|
||||
if (ldapResult.email) {
|
||||
const byEmail = await this.prisma.user.findFirst({
|
||||
where: { email: ldapResult.email, deletedAt: null, status: UserStatus.ACTIVE },
|
||||
include: { pinCode: true }
|
||||
});
|
||||
if (byEmail) {
|
||||
await this.prisma.linkedAccount.upsert({
|
||||
where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } },
|
||||
create: {
|
||||
userId: byEmail.id,
|
||||
providerName: 'ldap',
|
||||
providerId: ldapResult.dn,
|
||||
email: ldapResult.email,
|
||||
displayName: ldapResult.displayName
|
||||
},
|
||||
update: { email: ldapResult.email, displayName: ldapResult.displayName }
|
||||
});
|
||||
return byEmail;
|
||||
}
|
||||
}
|
||||
|
||||
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
||||
if (!lockAcquired) {
|
||||
throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const usersCount = await tx.user.count({ where: { deletedAt: null } });
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: ldapResult.email ?? undefined,
|
||||
username: ldapResult.username,
|
||||
displayName: ldapResult.displayName,
|
||||
passwordHash: null,
|
||||
isSuperAdmin: usersCount === 0,
|
||||
linkedAccounts: {
|
||||
create: {
|
||||
providerName: 'ldap',
|
||||
providerId: ldapResult.dn,
|
||||
email: ldapResult.email,
|
||||
displayName: ldapResult.displayName
|
||||
}
|
||||
}
|
||||
},
|
||||
include: { pinCode: true }
|
||||
});
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
|
||||
);
|
||||
} finally {
|
||||
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) {
|
||||
return this.prisma.device.upsert({
|
||||
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
|
||||
create: {
|
||||
userId,
|
||||
fingerprint: command.fingerprint,
|
||||
name: command.deviceName ?? 'Новое устройство',
|
||||
type: this.toDeviceType(command.deviceType),
|
||||
lastIp: command.ipAddress
|
||||
},
|
||||
update: {
|
||||
lastSeenAt: new Date(),
|
||||
lastIp: command.ipAddress,
|
||||
name: command.deviceName ?? undefined,
|
||||
type: this.toDeviceType(command.deviceType)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
|
||||
const pinVerified = !user.pinCode?.isEnabled;
|
||||
const refreshToken = randomBytes(48).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
deviceId,
|
||||
refreshHash: await bcrypt.hash(refreshToken, 12),
|
||||
pinVerified,
|
||||
status: pinVerified ? SessionStatus.ACTIVE : SessionStatus.LOCKED,
|
||||
ipAddress: command.ipAddress,
|
||||
userAgent: command.userAgent,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: await this.issueAccessToken(user, session.id, pinVerified),
|
||||
refreshToken,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
pinVerified,
|
||||
user: await this.toPublicUser(user),
|
||||
sessionId: session.id
|
||||
};
|
||||
}
|
||||
|
||||
private async issueTempPasswordToken(userId: string) {
|
||||
return this.jwt.signAsync(
|
||||
{ sub: userId, typ: 'password_challenge' },
|
||||
{ secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' }
|
||||
);
|
||||
}
|
||||
|
||||
private async findUserByRecipient(recipient: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: recipient.includes('@')
|
||||
? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE }
|
||||
: { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE },
|
||||
include: { pinCode: true }
|
||||
});
|
||||
}
|
||||
|
||||
private async findUserByTempPasswordToken(tempAuthToken: string) {
|
||||
const payload = await this.jwt.verifyAsync<{ sub: string; typ: string }>(tempAuthToken, {
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
issuer: 'id.lendry.ru'
|
||||
});
|
||||
if (payload.typ !== 'password_challenge') throw new UnauthorizedException('Временный токен недействителен');
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, include: { pinCode: true } });
|
||||
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async findOrCreatePasswordlessUser(recipient: string) {
|
||||
const existingUser = await this.findUserByRecipient(recipient);
|
||||
if (existingUser) return existingUser;
|
||||
|
||||
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
||||
if (!lockAcquired) throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
|
||||
try {
|
||||
return this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const usersCount = await tx.user.count({ where: { deletedAt: null } });
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: recipient.includes('@') ? recipient : undefined,
|
||||
phone: recipient.includes('@') ? undefined : recipient,
|
||||
passwordHash: null,
|
||||
displayName: recipient,
|
||||
isSuperAdmin: usersCount === 0
|
||||
}
|
||||
});
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
|
||||
);
|
||||
} finally {
|
||||
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
|
||||
}
|
||||
}
|
||||
|
||||
async getMe(userId: string): Promise<PublicUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
|
||||
return this.toPublicUser(user);
|
||||
}
|
||||
|
||||
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status'>): Promise<PublicUser> {
|
||||
const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
backupEmail: user.backupEmail,
|
||||
backupPhone: user.backupPhone,
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl),
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
status: user.status,
|
||||
roles: access.roles,
|
||||
permissions: access.permissions,
|
||||
canAccessAdmin: access.canAccessAdmin,
|
||||
canManageRoles: access.canManageRoles,
|
||||
canManageOAuth: access.canManageOAuth,
|
||||
canManageUsers: access.canManageUsers,
|
||||
canManageSettings: access.canManageSettings,
|
||||
canViewUsers: access.canViewUsers,
|
||||
canViewUserDocuments: access.canViewUserDocuments
|
||||
};
|
||||
}
|
||||
|
||||
async issueAccessToken(user: Pick<User, 'id' | 'isSuperAdmin'>, sessionId: string, pinVerified: boolean) {
|
||||
return this.jwt.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
sessionId,
|
||||
pinVerified,
|
||||
isSuperAdmin: user.isSuperAdmin
|
||||
},
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: '15m',
|
||||
issuer: 'id.lendry.ru'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async writeSignInEvent(userId: string, success: boolean, command: LoginCommand, deviceId?: string, reason?: string) {
|
||||
await this.prisma.signInEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
deviceId,
|
||||
success,
|
||||
reason,
|
||||
ipAddress: command.ipAddress,
|
||||
userAgent: command.userAgent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toDeviceType(value?: string): DeviceType {
|
||||
if (!value) return DeviceType.WEB;
|
||||
const normalized = value.toUpperCase();
|
||||
return Object.values(DeviceType).includes(normalized as DeviceType) ? (normalized as DeviceType) : DeviceType.OTHER;
|
||||
}
|
||||
|
||||
private isUniqueError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
|
||||
}
|
||||
}
|
||||
457
apps/sso-core/src/domain/chat.service.ts
Normal file
457
apps/sso-core/src/domain/chat.service.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { FamilyService } from './family.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
interface PollPayload {
|
||||
question: string;
|
||||
options: string[];
|
||||
allowsMultiple?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly family: FamilyService,
|
||||
private readonly notifications: NotificationsService
|
||||
) {}
|
||||
|
||||
async listRooms(userId: string, groupId: string) {
|
||||
await this.family.assertFamilyMember(groupId, userId);
|
||||
let generalRoom = await this.prisma.chatRoom.findFirst({ where: { groupId, type: 'GENERAL' } });
|
||||
if (!generalRoom) {
|
||||
const members = await this.prisma.familyMember.findMany({ where: { groupId } });
|
||||
generalRoom = await this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'GENERAL',
|
||||
name: 'Общий чат',
|
||||
members: { create: members.map((member) => ({ userId: member.userId })) }
|
||||
}
|
||||
});
|
||||
}
|
||||
const rooms = await this.prisma.chatRoom.findMany({
|
||||
where: {
|
||||
groupId,
|
||||
members: { some: { userId } }
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: true } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [{ type: 'asc' }, { updatedAt: 'desc' }]
|
||||
});
|
||||
|
||||
return {
|
||||
rooms: await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const membership = room.members.find((member) => member.userId === userId);
|
||||
const lastMessage = room.messages[0];
|
||||
return {
|
||||
id: room.id,
|
||||
groupId: room.groupId,
|
||||
type: room.type,
|
||||
name: room.name,
|
||||
hasAvatar: room.hasAvatar,
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
members: room.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
hasAvatar: Boolean(member.user.avatarStorageKey),
|
||||
notificationsMuted: member.notificationsMuted
|
||||
})),
|
||||
lastMessage: lastMessage ? await this.toMessage(lastMessage, userId) : undefined
|
||||
};
|
||||
})
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) {
|
||||
const group = await this.family.assertFamilyMember(groupId, userId);
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new BadRequestException('Укажите название группового чата');
|
||||
}
|
||||
|
||||
const uniqueMembers = Array.from(new Set([userId, ...memberUserIds]));
|
||||
for (const memberId of uniqueMembers) {
|
||||
if (!group.members.some((member) => member.userId === memberId)) {
|
||||
throw new BadRequestException('Все участники чата должны состоять в семье');
|
||||
}
|
||||
}
|
||||
|
||||
const room = await this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'GROUP',
|
||||
name: trimmedName,
|
||||
createdById: userId,
|
||||
members: {
|
||||
create: uniqueMembers.map((memberId) => ({ userId: memberId }))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: true } },
|
||||
messages: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
groupId: room.groupId,
|
||||
type: room.type,
|
||||
name: room.name,
|
||||
hasAvatar: room.hasAvatar,
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
members: room.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
hasAvatar: Boolean(member.user.avatarStorageKey),
|
||||
notificationsMuted: member.notificationsMuted
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
if (name !== undefined) {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new BadRequestException('Укажите название чата');
|
||||
}
|
||||
if (room.type === 'GENERAL') {
|
||||
throw new BadRequestException('Нельзя переименовать общий чат');
|
||||
}
|
||||
await this.prisma.chatRoom.update({ where: { id: roomId }, data: { name: trimmedName } });
|
||||
}
|
||||
if (notificationsMuted !== undefined) {
|
||||
await this.prisma.chatRoomMember.update({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
data: { notificationsMuted }
|
||||
});
|
||||
}
|
||||
return this.getRoomResponse(roomId, userId);
|
||||
}
|
||||
|
||||
async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) {
|
||||
await this.getRoomForMember(roomId, userId);
|
||||
const take = Math.min(Math.max(limit, 1), 100);
|
||||
|
||||
let cursorDate: Date | undefined;
|
||||
if (beforeMessageId) {
|
||||
const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } });
|
||||
if (cursor) cursorDate = cursor.createdAt;
|
||||
}
|
||||
|
||||
const messages = await this.prisma.chatMessage.findMany({
|
||||
where: {
|
||||
roomId,
|
||||
...(cursorDate ? { createdAt: { lt: cursorDate } } : {})
|
||||
},
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take
|
||||
});
|
||||
|
||||
const ordered = messages.reverse();
|
||||
return {
|
||||
messages: await Promise.all(ordered.map((message) => this.toMessage(message, userId)))
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
userId: string,
|
||||
roomId: string,
|
||||
type: string,
|
||||
content?: string,
|
||||
replyToId?: string,
|
||||
storageKey?: string,
|
||||
mimeType?: string,
|
||||
metadataJson?: string,
|
||||
poll?: PollPayload
|
||||
) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']);
|
||||
if (!allowedTypes.has(type)) {
|
||||
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
||||
}
|
||||
|
||||
if (type === 'POLL') {
|
||||
if (!poll?.question?.trim() || !poll.options?.length) {
|
||||
throw new BadRequestException('Укажите вопрос и варианты опроса');
|
||||
}
|
||||
if (poll.options.length < 2) {
|
||||
throw new BadRequestException('В опросе должно быть минимум 2 варианта');
|
||||
}
|
||||
} else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'FILE') {
|
||||
if (!content?.trim()) {
|
||||
throw new BadRequestException('Сообщение не может быть пустым');
|
||||
}
|
||||
}
|
||||
|
||||
if ((type === 'IMAGE' || type === 'AUDIO' || type === 'VOICE' || type === 'FILE') && !storageKey) {
|
||||
throw new BadRequestException('Не указан файл для отправки');
|
||||
}
|
||||
|
||||
if (storageKey && !storageKey.startsWith(`chat/${roomId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ медиафайла');
|
||||
}
|
||||
|
||||
const metadata = metadataJson ? JSON.parse(metadataJson) : undefined;
|
||||
|
||||
const message = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.chatMessage.create({
|
||||
data: {
|
||||
roomId,
|
||||
senderId: userId,
|
||||
type,
|
||||
content: content?.trim() || null,
|
||||
replyToId: replyToId || null,
|
||||
storageKey: storageKey || null,
|
||||
mimeType: mimeType || null,
|
||||
metadata: metadata ?? undefined
|
||||
},
|
||||
include: { sender: true }
|
||||
});
|
||||
|
||||
if (type === 'POLL' && poll) {
|
||||
await tx.chatPoll.create({
|
||||
data: {
|
||||
messageId: created.id,
|
||||
question: poll.question.trim(),
|
||||
allowsMultiple: poll.allowsMultiple ?? false,
|
||||
isAnonymous: poll.isAnonymous ?? false,
|
||||
options: {
|
||||
create: poll.options.map((text) => ({ text: text.trim() })).filter((item) => item.text)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await tx.chatRoom.update({ where: { id: roomId }, data: { updatedAt: new Date() } });
|
||||
return created;
|
||||
});
|
||||
|
||||
const fullMessage = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: message.id },
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
}
|
||||
});
|
||||
|
||||
const response = await this.toMessage(fullMessage!, userId);
|
||||
await this.notifyRoomMembers(room, userId, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
async votePoll(userId: string, messageId: string, optionIds: string[]) {
|
||||
const message = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
poll: { include: { options: true } },
|
||||
room: { include: { members: true, group: true } }
|
||||
}
|
||||
});
|
||||
if (!message?.poll) {
|
||||
throw new NotFoundException('Опрос не найден');
|
||||
}
|
||||
await this.getRoomForMember(message.roomId, userId);
|
||||
|
||||
const validOptionIds = new Set(message.poll.options.map((option) => option.id));
|
||||
for (const optionId of optionIds) {
|
||||
if (!validOptionIds.has(optionId)) {
|
||||
throw new BadRequestException('Некорректный вариант опроса');
|
||||
}
|
||||
}
|
||||
if (!message.poll.allowsMultiple && optionIds.length > 1) {
|
||||
throw new BadRequestException('Можно выбрать только один вариант');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.chatPollVote.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
option: { pollId: message.poll!.id }
|
||||
}
|
||||
});
|
||||
for (const optionId of optionIds) {
|
||||
await tx.chatPollVote.create({ data: { optionId, userId } });
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
}
|
||||
});
|
||||
return this.toMessage(updated!, userId);
|
||||
}
|
||||
|
||||
async setRoomNotificationsMuted(userId: string, roomId: string, muted: boolean) {
|
||||
await this.getRoomForMember(roomId, userId);
|
||||
await this.prisma.chatRoomMember.update({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
data: { notificationsMuted: muted }
|
||||
});
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
async assertRoomMember(roomId: string, userId: string) {
|
||||
return this.getRoomForMember(roomId, userId);
|
||||
}
|
||||
|
||||
private async getRoomForMember(roomId: string, userId: string) {
|
||||
const room = await this.prisma.chatRoom.findUnique({
|
||||
where: { id: roomId },
|
||||
include: {
|
||||
members: true,
|
||||
group: { include: { members: true } }
|
||||
}
|
||||
});
|
||||
if (!room) {
|
||||
throw new NotFoundException('Чат не найден');
|
||||
}
|
||||
if (!room.members.some((member) => member.userId === userId)) {
|
||||
throw new ForbiddenException('Вы не состоите в этом чате');
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private async getRoomResponse(roomId: string, userId: string) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
const full = await this.prisma.chatRoom.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { include: { user: true } } }
|
||||
});
|
||||
return {
|
||||
id: full!.id,
|
||||
groupId: full!.groupId,
|
||||
type: full!.type,
|
||||
name: full!.name,
|
||||
hasAvatar: full!.hasAvatar,
|
||||
updatedAt: full!.updatedAt.toISOString(),
|
||||
members: full!.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
hasAvatar: Boolean(member.user.avatarStorageKey),
|
||||
notificationsMuted: member.notificationsMuted
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private async notifyRoomMembers(
|
||||
room: { id: string; name: string; members: Array<{ userId: string; notificationsMuted: boolean }> },
|
||||
senderId: string,
|
||||
message: Awaited<ReturnType<ChatService['toMessage']>>
|
||||
) {
|
||||
const sender = await this.prisma.user.findUnique({ where: { id: senderId } });
|
||||
const preview =
|
||||
message.type === 'TEXT' || message.type === 'EMOJI'
|
||||
? (message.content ?? '')
|
||||
: message.type === 'POLL'
|
||||
? '📊 Опрос'
|
||||
: message.type === 'VOICE'
|
||||
? '🎤 Голосовое'
|
||||
: message.type === 'AUDIO'
|
||||
? '🎵 Аудио'
|
||||
: message.type === 'FILE'
|
||||
? '📄 Файл'
|
||||
: message.type === 'IMAGE'
|
||||
? '📷 Фото'
|
||||
: '📎 Медиа';
|
||||
|
||||
for (const member of room.members) {
|
||||
if (member.userId === senderId) continue;
|
||||
await this.notifications.publishRealtime(member.userId, 'chat_message', room.name, preview.slice(0, 120), {
|
||||
roomId: room.id,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of room.members) {
|
||||
if (member.userId === senderId || member.notificationsMuted) continue;
|
||||
await this.notifications.create(
|
||||
member.userId,
|
||||
'chat_message',
|
||||
room.name,
|
||||
`${sender?.displayName ?? 'Участник'}: ${preview.slice(0, 120)}`,
|
||||
{ roomId: room.id, messageId: message.id },
|
||||
{ skipPublish: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async toMessage(
|
||||
message: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
type: string;
|
||||
content: string | null;
|
||||
replyToId: string | null;
|
||||
storageKey: string | null;
|
||||
mimeType: string | null;
|
||||
metadata: unknown;
|
||||
createdAt: Date;
|
||||
sender: { displayName: string; avatarStorageKey: string | null };
|
||||
poll?: {
|
||||
id: string;
|
||||
question: string;
|
||||
allowsMultiple: boolean;
|
||||
isAnonymous: boolean;
|
||||
closedAt: Date | null;
|
||||
options: Array<{ id: string; text: string; votes: Array<{ userId: string }> }>;
|
||||
} | null;
|
||||
},
|
||||
viewerId: string
|
||||
) {
|
||||
return {
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
senderId: message.senderId,
|
||||
senderName: message.sender.displayName,
|
||||
senderHasAvatar: Boolean(message.sender.avatarStorageKey),
|
||||
type: message.type,
|
||||
content: message.content ?? undefined,
|
||||
replyToId: message.replyToId ?? undefined,
|
||||
storageKey: message.storageKey ?? undefined,
|
||||
mimeType: message.mimeType ?? undefined,
|
||||
metadataJson: message.metadata ? JSON.stringify(message.metadata) : undefined,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
poll: message.poll
|
||||
? {
|
||||
id: message.poll.id,
|
||||
question: message.poll.question,
|
||||
allowsMultiple: message.poll.allowsMultiple,
|
||||
isAnonymous: message.poll.isAnonymous,
|
||||
closedAt: message.poll.closedAt?.toISOString(),
|
||||
options: message.poll.options.map((option) => ({
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
voteCount: option.votes.length,
|
||||
votedByMe: option.votes.some((vote) => vote.userId === viewerId)
|
||||
}))
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
140
apps/sso-core/src/domain/documents.service.ts
Normal file
140
apps/sso-core/src/domain/documents.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { EncryptionService } from '../infra/encryption.service';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
export const DOCUMENT_TYPES = [
|
||||
'PASSPORT_RF',
|
||||
'FOREIGN_PASSPORT',
|
||||
'BIRTH_CERTIFICATE',
|
||||
'DRIVER_LICENSE',
|
||||
'VEHICLE_REGISTRATION',
|
||||
'OMS',
|
||||
'DMS',
|
||||
'INN',
|
||||
'SNILS'
|
||||
] as const;
|
||||
|
||||
export type DocumentType = (typeof DOCUMENT_TYPES)[number];
|
||||
|
||||
interface CreateDocumentCommand {
|
||||
userId: string;
|
||||
type: string;
|
||||
number: string;
|
||||
issuedAt?: string;
|
||||
expiresAt?: string;
|
||||
metadataJson?: string;
|
||||
}
|
||||
|
||||
interface UpdateDocumentCommand {
|
||||
documentId: string;
|
||||
userId: string;
|
||||
number?: string;
|
||||
issuedAt?: string;
|
||||
expiresAt?: string;
|
||||
metadataJson?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService
|
||||
) {}
|
||||
|
||||
private assertType(type: string): asserts type is DocumentType {
|
||||
if (!DOCUMENT_TYPES.includes(type as DocumentType)) {
|
||||
throw new BadRequestException('Некорректный тип документа');
|
||||
}
|
||||
}
|
||||
|
||||
async create(command: CreateDocumentCommand) {
|
||||
this.assertType(command.type);
|
||||
|
||||
const metadata = command.metadataJson
|
||||
? ({ encryptedPayload: this.encryption.encrypt(command.metadataJson) } satisfies Prisma.InputJsonObject)
|
||||
: undefined;
|
||||
|
||||
const document = await this.prisma.userDocument.create({
|
||||
data: {
|
||||
userId: command.userId,
|
||||
type: command.type,
|
||||
number: this.encryption.encrypt(command.number || '—'),
|
||||
issuedAt: command.issuedAt ? new Date(command.issuedAt) : undefined,
|
||||
expiresAt: command.expiresAt ? new Date(command.expiresAt) : undefined,
|
||||
metadata
|
||||
}
|
||||
});
|
||||
|
||||
return this.toResponse(document);
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const documents = await this.prisma.userDocument.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
return { documents: documents.map((document) => this.toResponse(document)) };
|
||||
}
|
||||
|
||||
async get(documentId: string, userId: string) {
|
||||
const document = await this.prisma.userDocument.findFirst({
|
||||
where: { id: documentId, userId }
|
||||
});
|
||||
if (!document) {
|
||||
throw new NotFoundException('Документ не найден');
|
||||
}
|
||||
return this.toResponse(document);
|
||||
}
|
||||
|
||||
async update(command: UpdateDocumentCommand) {
|
||||
const existing = await this.prisma.userDocument.findFirst({
|
||||
where: { id: command.documentId, userId: command.userId }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Документ не найден');
|
||||
}
|
||||
|
||||
const metadata = command.metadataJson
|
||||
? ({ encryptedPayload: this.encryption.encrypt(command.metadataJson) } satisfies Prisma.InputJsonObject)
|
||||
: undefined;
|
||||
|
||||
const document = await this.prisma.userDocument.update({
|
||||
where: { id: command.documentId },
|
||||
data: {
|
||||
number: command.number ? this.encryption.encrypt(command.number) : undefined,
|
||||
issuedAt: command.issuedAt ? new Date(command.issuedAt) : undefined,
|
||||
expiresAt: command.expiresAt ? new Date(command.expiresAt) : undefined,
|
||||
metadata
|
||||
}
|
||||
});
|
||||
|
||||
return this.toResponse(document);
|
||||
}
|
||||
|
||||
async delete(documentId: string, userId: string) {
|
||||
const existing = await this.prisma.userDocument.findFirst({
|
||||
where: { id: documentId, userId }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Документ не найден');
|
||||
}
|
||||
await this.prisma.userDocument.delete({ where: { id: documentId } });
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
private toResponse(document: Awaited<ReturnType<PrismaService['userDocument']['create']>>) {
|
||||
const metadata = document.metadata as { encryptedPayload?: string } | null;
|
||||
return {
|
||||
id: document.id,
|
||||
userId: document.userId,
|
||||
type: document.type,
|
||||
number: this.encryption.decrypt(document.number),
|
||||
issuedAt: document.issuedAt?.toISOString(),
|
||||
expiresAt: document.expiresAt?.toISOString(),
|
||||
metadataJson: metadata?.encryptedPayload ? this.encryption.decrypt(metadata.encryptedPayload) : undefined,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
updatedAt: document.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
61
apps/sso-core/src/domain/dto.ts
Normal file
61
apps/sso-core/src/domain/dto.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface RegisterCommand {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
displayName: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface LoginCommand {
|
||||
login: string;
|
||||
password: string;
|
||||
deviceName?: string;
|
||||
deviceType?: string;
|
||||
fingerprint: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
pinVerified: boolean;
|
||||
sessionId: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
backupEmail?: string | null;
|
||||
backupPhone?: string | null;
|
||||
displayName: string;
|
||||
username?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
hasAvatar?: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
status: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
canAccessAdmin?: boolean;
|
||||
canManageRoles?: boolean;
|
||||
canManageOAuth?: boolean;
|
||||
canManageUsers?: boolean;
|
||||
canManageSettings?: boolean;
|
||||
canViewUsers?: boolean;
|
||||
canViewUserDocuments?: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyPinCommand {
|
||||
sessionId: string;
|
||||
pin: string;
|
||||
}
|
||||
|
||||
export interface UpsertSettingCommand {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
786
apps/sso-core/src/domain/family.service.ts
Normal file
786
apps/sso-core/src/domain/family.service.ts
Normal file
@@ -0,0 +1,786 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
|
||||
|
||||
const INVITE_TTL_DAYS = 7;
|
||||
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
||||
export class FamilyService {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly prisma: PrismaService,
|
||||
|
||||
private readonly settings: SettingsService,
|
||||
|
||||
private readonly notifications: NotificationsService
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
async create(ownerId: string, name: string) {
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
|
||||
throw new BadRequestException('Укажите название семьи');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const group = await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
const created = await tx.familyGroup.create({
|
||||
|
||||
data: {
|
||||
|
||||
ownerId,
|
||||
|
||||
name: trimmedName,
|
||||
|
||||
members: { create: { userId: ownerId, role: 'owner' } }
|
||||
|
||||
},
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const generalRoom = await tx.chatRoom.create({
|
||||
|
||||
data: {
|
||||
|
||||
groupId: created.id,
|
||||
|
||||
type: 'GENERAL',
|
||||
|
||||
name: 'Общий чат',
|
||||
|
||||
createdById: ownerId,
|
||||
|
||||
members: { create: { userId: ownerId } }
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return { ...created, generalRoomId: generalRoom.id };
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return this.toGroup(group);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async list(userId: string) {
|
||||
|
||||
const groups = await this.prisma.familyGroup.findMany({
|
||||
|
||||
where: { OR: [{ ownerId: userId }, { members: { some: { userId } } }] },
|
||||
|
||||
include: { members: { include: { user: true } } },
|
||||
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
|
||||
});
|
||||
|
||||
return { groups: groups.map((group) => this.toGroup(group)) };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async get(requesterId: string, groupId: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertMember(group, requesterId);
|
||||
|
||||
return this.toGroup(group);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async update(requesterId: string, groupId: string, name: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertOwner(group, requesterId);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
|
||||
throw new BadRequestException('Укажите название семьи');
|
||||
|
||||
}
|
||||
|
||||
const updated = await this.prisma.familyGroup.update({
|
||||
|
||||
where: { id: groupId },
|
||||
|
||||
data: { name: trimmedName },
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
|
||||
});
|
||||
|
||||
return this.toGroup(updated);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async delete(requesterId: string, groupId: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertOwner(group, requesterId);
|
||||
|
||||
await this.prisma.familyGroup.delete({ where: { id: groupId } });
|
||||
|
||||
return { count: 1 };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async addMember(groupId: string, userId: string, role: string) {
|
||||
|
||||
const group = await this.prisma.familyGroup.findUnique({
|
||||
|
||||
where: { id: groupId },
|
||||
|
||||
include: { members: true }
|
||||
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
|
||||
throw new BadRequestException('Семейная группа не найдена');
|
||||
|
||||
}
|
||||
|
||||
const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6);
|
||||
|
||||
if (group.members.length >= maxMembers) {
|
||||
|
||||
throw new BadRequestException(`В группе может быть не более ${maxMembers} участников`);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const member = await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
const created = await tx.familyMember.create({ data: { groupId, userId, role } });
|
||||
|
||||
const rooms = await tx.chatRoom.findMany({ where: { groupId } });
|
||||
|
||||
for (const room of rooms) {
|
||||
|
||||
if (room.type === 'GENERAL') {
|
||||
|
||||
await tx.chatRoomMember.upsert({
|
||||
|
||||
where: { roomId_userId: { roomId: room.id, userId } },
|
||||
|
||||
create: { roomId: room.id, userId },
|
||||
|
||||
update: {}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return created;
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return this.toMember(member);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async removeMember(requesterId: string, memberId: string) {
|
||||
|
||||
const member = await this.prisma.familyMember.findUnique({
|
||||
|
||||
where: { id: memberId },
|
||||
|
||||
include: { group: { include: { members: true } } }
|
||||
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
|
||||
throw new NotFoundException('Участник не найден');
|
||||
|
||||
}
|
||||
|
||||
const isSelf = member.userId === requesterId;
|
||||
|
||||
const isOwner = member.group.ownerId === requesterId;
|
||||
|
||||
if (!isSelf && !isOwner) {
|
||||
|
||||
throw new ForbiddenException('Недостаточно прав для удаления участника');
|
||||
|
||||
}
|
||||
|
||||
if (member.role === 'owner') {
|
||||
|
||||
throw new BadRequestException('Нельзя удалить владельца семьи');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: member.groupId } } });
|
||||
|
||||
await tx.familyMember.delete({ where: { id: memberId } });
|
||||
|
||||
});
|
||||
|
||||
return { count: 1 };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async sendInvite(requesterId: string, groupId: string, target: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertMember(group, requesterId);
|
||||
|
||||
|
||||
|
||||
const trimmedTarget = target.trim();
|
||||
|
||||
if (!trimmedTarget) {
|
||||
|
||||
throw new BadRequestException('Укажите email, телефон или логин приглашаемого');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const invitee = await this.findUserByContact(trimmedTarget);
|
||||
|
||||
if (invitee?.id === requesterId) {
|
||||
|
||||
throw new BadRequestException('Нельзя пригласить самого себя');
|
||||
|
||||
}
|
||||
|
||||
if (invitee && group.members.some((member) => member.userId === invitee.id)) {
|
||||
|
||||
throw new BadRequestException('Пользователь уже состоит в семье');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const existingPending = invitee
|
||||
|
||||
? await this.prisma.familyInvite.findFirst({
|
||||
|
||||
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
|
||||
|
||||
})
|
||||
|
||||
: null;
|
||||
|
||||
if (existingPending) {
|
||||
|
||||
throw new BadRequestException('Приглашение уже отправлено');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } });
|
||||
|
||||
const invite = await this.prisma.familyInvite.create({
|
||||
|
||||
data: {
|
||||
|
||||
groupId,
|
||||
|
||||
inviterId: requesterId,
|
||||
|
||||
inviteeUserId: invitee?.id,
|
||||
|
||||
targetEmail: trimmedTarget.includes('@') ? trimmedTarget : undefined,
|
||||
|
||||
targetPhone: !trimmedTarget.includes('@') && /^\+?\d/.test(trimmedTarget) ? trimmedTarget : undefined,
|
||||
|
||||
expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
|
||||
},
|
||||
|
||||
include: {
|
||||
|
||||
group: true,
|
||||
|
||||
inviter: true
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (invitee) {
|
||||
|
||||
await this.notifications.create(
|
||||
|
||||
invitee.id,
|
||||
|
||||
'family_invite',
|
||||
|
||||
'Приглашение в семью',
|
||||
|
||||
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
|
||||
|
||||
{ inviteId: invite.id, groupId, groupName: group.name }
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return this.toInvite(invite);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async respondInvite(userId: string, inviteId: string, accept: boolean) {
|
||||
|
||||
const invite = await this.prisma.familyInvite.findUnique({
|
||||
|
||||
where: { id: inviteId },
|
||||
|
||||
include: { group: { include: { members: true } }, inviter: true }
|
||||
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
|
||||
throw new NotFoundException('Приглашение не найдено');
|
||||
|
||||
}
|
||||
|
||||
if (invite.status !== 'PENDING') {
|
||||
|
||||
throw new BadRequestException('Приглашение уже обработано');
|
||||
|
||||
}
|
||||
|
||||
if (invite.expiresAt.getTime() < Date.now()) {
|
||||
|
||||
await this.prisma.familyInvite.update({ where: { id: inviteId }, data: { status: 'EXPIRED' } });
|
||||
|
||||
throw new BadRequestException('Срок действия приглашения истёк');
|
||||
|
||||
}
|
||||
|
||||
if (invite.inviteeUserId && invite.inviteeUserId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Это приглашение предназначено другому пользователю');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!accept) {
|
||||
|
||||
const declined = await this.prisma.familyInvite.update({
|
||||
|
||||
where: { id: inviteId },
|
||||
|
||||
data: { status: 'DECLINED', inviteeUserId: invite.inviteeUserId ?? userId },
|
||||
|
||||
include: { group: true, inviter: true }
|
||||
|
||||
});
|
||||
|
||||
await this.notifications.dismissFamilyInvite(userId, inviteId);
|
||||
|
||||
return this.toInvite(declined);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6);
|
||||
|
||||
if (invite.group.members.length >= maxMembers) {
|
||||
|
||||
throw new BadRequestException(`В семье уже максимум ${maxMembers} участников`);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
await tx.familyInvite.update({
|
||||
|
||||
where: { id: inviteId },
|
||||
|
||||
data: { status: 'ACCEPTED', inviteeUserId: userId }
|
||||
|
||||
});
|
||||
|
||||
await tx.familyMember.create({
|
||||
|
||||
data: { groupId: invite.groupId, userId, role: 'member' }
|
||||
|
||||
});
|
||||
|
||||
const rooms = await tx.chatRoom.findMany({ where: { groupId: invite.groupId } });
|
||||
|
||||
for (const room of rooms) {
|
||||
|
||||
if (room.type === 'GENERAL') {
|
||||
|
||||
await tx.chatRoomMember.upsert({
|
||||
|
||||
where: { roomId_userId: { roomId: room.id, userId } },
|
||||
|
||||
create: { roomId: room.id, userId },
|
||||
|
||||
update: {}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
await this.notifications.create(
|
||||
|
||||
invite.inviterId,
|
||||
|
||||
'family_invite_accepted',
|
||||
|
||||
'Приглашение принято',
|
||||
|
||||
`${user?.displayName ?? 'Пользователь'} присоединился к «${invite.group.name}»`,
|
||||
|
||||
{ groupId: invite.groupId, userId }
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
const updated = await this.prisma.familyInvite.findUnique({
|
||||
|
||||
where: { id: inviteId },
|
||||
|
||||
include: { group: true, inviter: true }
|
||||
|
||||
});
|
||||
|
||||
await this.notifications.dismissFamilyInvite(userId, inviteId);
|
||||
|
||||
return this.toInvite(updated!);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async listInvites(userId: string, groupId?: string) {
|
||||
|
||||
const invites = await this.prisma.familyInvite.findMany({
|
||||
|
||||
where: {
|
||||
|
||||
...(groupId ? { groupId } : {}),
|
||||
inviteeUserId: userId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
|
||||
include: { group: true, inviter: true },
|
||||
|
||||
orderBy: { createdAt: 'desc' }
|
||||
|
||||
});
|
||||
|
||||
return { invites: invites.map((invite) => this.toInvite(invite)) };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async assertFamilyMember(groupId: string, userId: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertMember(group, userId);
|
||||
|
||||
return group;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async getGroupWithMembers(groupId: string) {
|
||||
|
||||
const group = await this.prisma.familyGroup.findUnique({
|
||||
|
||||
where: { id: groupId },
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
|
||||
throw new NotFoundException('Семейная группа не найдена');
|
||||
|
||||
}
|
||||
|
||||
return group;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private assertMember(group: { members: Array<{ userId: string }> }, userId: string) {
|
||||
|
||||
if (!group.members.some((member) => member.userId === userId)) {
|
||||
|
||||
throw new ForbiddenException('Вы не состоите в этой семье');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private assertOwner(group: { ownerId: string }, userId: string) {
|
||||
|
||||
if (group.ownerId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Только владелец семьи может выполнить это действие');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async findUserByContact(target: string) {
|
||||
|
||||
if (target.includes('@')) {
|
||||
|
||||
return this.prisma.user.findFirst({ where: { email: target, status: 'ACTIVE' } });
|
||||
|
||||
}
|
||||
|
||||
const normalizedPhone = target.replace(/\D/g, '');
|
||||
|
||||
return this.prisma.user.findFirst({
|
||||
|
||||
where: {
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
OR: [{ phone: target }, { phone: { contains: normalizedPhone.slice(-10) } }, { username: target }]
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private toGroup(group: {
|
||||
|
||||
id: string;
|
||||
|
||||
ownerId: string;
|
||||
|
||||
name: string;
|
||||
|
||||
hasAvatar: boolean;
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
updatedAt: Date;
|
||||
|
||||
members: Array<{
|
||||
|
||||
id: string;
|
||||
|
||||
groupId: string;
|
||||
|
||||
userId: string;
|
||||
|
||||
role: string;
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
user?: { displayName: string; avatarStorageKey: string | null };
|
||||
|
||||
}>;
|
||||
|
||||
}) {
|
||||
|
||||
return {
|
||||
|
||||
id: group.id,
|
||||
|
||||
ownerId: group.ownerId,
|
||||
|
||||
name: group.name,
|
||||
|
||||
hasAvatar: group.hasAvatar,
|
||||
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
|
||||
updatedAt: group.updatedAt.toISOString(),
|
||||
|
||||
members: group.members.map((member) => this.toMember(member))
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private toMember(member: {
|
||||
|
||||
id: string;
|
||||
|
||||
groupId: string;
|
||||
|
||||
userId: string;
|
||||
|
||||
role: string;
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
user?: { displayName: string; avatarStorageKey: string | null };
|
||||
|
||||
}) {
|
||||
|
||||
return {
|
||||
|
||||
id: member.id,
|
||||
|
||||
groupId: member.groupId,
|
||||
|
||||
userId: member.userId,
|
||||
|
||||
role: member.role,
|
||||
|
||||
displayName: member.user?.displayName ?? 'Участник',
|
||||
|
||||
hasAvatar: Boolean(member.user?.avatarStorageKey),
|
||||
|
||||
createdAt: member.createdAt.toISOString()
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private toInvite(invite: {
|
||||
|
||||
id: string;
|
||||
|
||||
groupId: string;
|
||||
|
||||
inviterId: string;
|
||||
|
||||
inviteeUserId: string | null;
|
||||
|
||||
targetEmail: string | null;
|
||||
|
||||
targetPhone: string | null;
|
||||
|
||||
status: string;
|
||||
|
||||
expiresAt: Date;
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
group: { name: string };
|
||||
|
||||
inviter: { displayName: string };
|
||||
|
||||
}) {
|
||||
|
||||
return {
|
||||
|
||||
id: invite.id,
|
||||
|
||||
groupId: invite.groupId,
|
||||
|
||||
groupName: invite.group.name,
|
||||
|
||||
inviterId: invite.inviterId,
|
||||
|
||||
inviterName: invite.inviter.displayName,
|
||||
|
||||
inviteeUserId: invite.inviteeUserId ?? undefined,
|
||||
|
||||
targetEmail: invite.targetEmail ?? undefined,
|
||||
|
||||
targetPhone: invite.targetPhone ?? undefined,
|
||||
|
||||
status: invite.status,
|
||||
|
||||
expiresAt: invite.expiresAt.toISOString(),
|
||||
|
||||
createdAt: invite.createdAt.toISOString()
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
283
apps/sso-core/src/domain/media.service.ts
Normal file
283
apps/sso-core/src/domain/media.service.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AccessService } from './access.service';
|
||||
import { MinioService } from '../infra/minio.service';
|
||||
|
||||
const AVATAR_ACCESS_TTL_SECONDS = 900;
|
||||
|
||||
interface MediaStreamPayload {
|
||||
purpose: 'media-stream';
|
||||
objectKey: string;
|
||||
ownerId: string;
|
||||
roomId?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly minio: MinioService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly access: AccessService
|
||||
) {}
|
||||
|
||||
async createAvatarUploadUrl(userId: string, contentType: string) {
|
||||
try {
|
||||
this.minio.assertImageContentType(contentType);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
const extension = this.minio.extensionForContentType(contentType);
|
||||
const storageKey = `avatars/${userId}/${crypto.randomUUID()}.${extension}`;
|
||||
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
|
||||
return { ...presigned, storageKey };
|
||||
}
|
||||
|
||||
async confirmAvatar(userId: string, storageKey: string) {
|
||||
if (!storageKey.startsWith(`avatars/${userId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ аватара');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
avatarStorageKey: storageKey,
|
||||
avatarUrl: null
|
||||
}
|
||||
});
|
||||
|
||||
return { userId, hasAvatar: true };
|
||||
}
|
||||
|
||||
async getAvatarAccessUrl(requesterId: string, targetUserId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: targetUserId } });
|
||||
if (!user?.avatarStorageKey) {
|
||||
throw new NotFoundException('Аватар не найден');
|
||||
}
|
||||
|
||||
if (requesterId !== targetUserId) {
|
||||
const requester = await this.prisma.user.findUnique({ where: { id: requesterId } });
|
||||
if (!requester?.isSuperAdmin) {
|
||||
throw new ForbiddenException('Нет доступа к аватару пользователя');
|
||||
}
|
||||
}
|
||||
|
||||
const token = await this.jwt.signAsync(
|
||||
{
|
||||
purpose: 'media-stream',
|
||||
objectKey: user.avatarStorageKey,
|
||||
ownerId: targetUserId
|
||||
} satisfies MediaStreamPayload,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
||||
issuer: 'id.lendry.ru'
|
||||
}
|
||||
);
|
||||
|
||||
const publicApiUrl = this.config.get<string>('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, '');
|
||||
return {
|
||||
accessUrl: `${publicApiUrl}/media/stream/${token}`,
|
||||
expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string) {
|
||||
try {
|
||||
this.minio.assertImageContentType(contentType);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
const document = await this.prisma.userDocument.findFirst({
|
||||
where: { id: documentId, userId }
|
||||
});
|
||||
if (!document) {
|
||||
throw new NotFoundException('Документ не найден');
|
||||
}
|
||||
|
||||
const extension = this.minio.extensionForContentType(contentType);
|
||||
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
|
||||
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
|
||||
return { ...presigned, storageKey };
|
||||
}
|
||||
|
||||
async resolveStreamToken(token: string, requesterId?: string) {
|
||||
let payload: MediaStreamPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<MediaStreamPayload>(token, {
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
issuer: 'id.lendry.ru'
|
||||
});
|
||||
} catch {
|
||||
throw new ForbiddenException('Ссылка недействительна или истекла');
|
||||
}
|
||||
|
||||
if (payload.purpose !== 'media-stream' || !payload.objectKey) {
|
||||
throw new ForbiddenException('Ссылка недействительна');
|
||||
}
|
||||
|
||||
if (payload.objectKey.startsWith('chat/')) {
|
||||
if (!requesterId) {
|
||||
throw new ForbiddenException('Для доступа к файлу чата требуется авторизация');
|
||||
}
|
||||
const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey);
|
||||
if (!roomId) {
|
||||
throw new ForbiddenException('Некорректный ключ файла чата');
|
||||
}
|
||||
await this.assertChatMember(roomId, requesterId);
|
||||
}
|
||||
|
||||
const contentType = this.minio.contentTypeForKey(payload.objectKey);
|
||||
|
||||
return {
|
||||
storageKey: payload.objectKey,
|
||||
contentType,
|
||||
fileName: payload.fileName
|
||||
};
|
||||
}
|
||||
|
||||
async getObjectStream(storageKey: string) {
|
||||
return this.minio.getClient().send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.minio.getBucket(),
|
||||
Key: storageKey
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string) {
|
||||
await this.access.assertCanViewUserDocuments(requesterId, targetUserId);
|
||||
|
||||
if (!storageKey.startsWith(`documents/${targetUserId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ фото документа');
|
||||
}
|
||||
|
||||
const token = await this.jwt.signAsync(
|
||||
{
|
||||
purpose: 'media-stream',
|
||||
objectKey: storageKey,
|
||||
ownerId: targetUserId
|
||||
} satisfies MediaStreamPayload,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
||||
issuer: 'id.lendry.ru'
|
||||
}
|
||||
);
|
||||
|
||||
const publicApiUrl = this.config.get<string>('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, '');
|
||||
return {
|
||||
accessUrl: `${publicApiUrl}/media/stream/${token}`,
|
||||
expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async createFamilyAvatarUploadUrl(requesterId: string, groupId: string, contentType: string) {
|
||||
await this.assertFamilyMember(groupId, requesterId);
|
||||
try {
|
||||
this.minio.assertImageContentType(contentType);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
const extension = this.minio.extensionForContentType(contentType);
|
||||
const storageKey = `families/${groupId}/${crypto.randomUUID()}.${extension}`;
|
||||
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
|
||||
return { ...presigned, storageKey };
|
||||
}
|
||||
|
||||
async confirmFamilyAvatar(requesterId: string, groupId: string, storageKey: string) {
|
||||
await this.assertFamilyMember(groupId, requesterId);
|
||||
if (!storageKey.startsWith(`families/${groupId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ аватара семьи');
|
||||
}
|
||||
await this.prisma.familyGroup.update({
|
||||
where: { id: groupId },
|
||||
data: { avatarStorageKey: storageKey, hasAvatar: true }
|
||||
});
|
||||
return { groupId, hasAvatar: true };
|
||||
}
|
||||
|
||||
async getFamilyAvatarAccessUrl(requesterId: string, groupId: string) {
|
||||
await this.assertFamilyMember(groupId, requesterId);
|
||||
const group = await this.prisma.familyGroup.findUnique({ where: { id: groupId } });
|
||||
if (!group?.avatarStorageKey) {
|
||||
throw new NotFoundException('Аватар семьи не найден');
|
||||
}
|
||||
return this.createStreamAccessUrl(group.avatarStorageKey, groupId);
|
||||
}
|
||||
|
||||
async createChatMediaUploadUrl(requesterId: string, roomId: string, contentType: string, fileName?: string) {
|
||||
await this.assertChatMember(roomId, requesterId);
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = this.minio.assertChatMediaContentType(contentType, fileName);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
const extension = this.minio.extensionForChatMedia(normalized, fileName);
|
||||
const storageKey = `chat/${roomId}/${crypto.randomUUID()}.${extension}`;
|
||||
const presigned = await this.minio.createPresignedUploadUrl(storageKey, normalized);
|
||||
return { ...presigned, storageKey };
|
||||
}
|
||||
|
||||
async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) {
|
||||
await this.assertChatMember(roomId, requesterId);
|
||||
if (!storageKey.startsWith(`chat/${roomId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ медиафайла');
|
||||
}
|
||||
return this.createStreamAccessUrl(storageKey, requesterId, { roomId, fileName });
|
||||
}
|
||||
|
||||
private extractChatRoomId(storageKey: string) {
|
||||
const [prefix, roomId] = storageKey.split('/');
|
||||
if (prefix !== 'chat' || !roomId) {
|
||||
return null;
|
||||
}
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private async createStreamAccessUrl(
|
||||
storageKey: string,
|
||||
ownerId: string,
|
||||
options?: { roomId?: string; fileName?: string }
|
||||
) {
|
||||
const token = await this.jwt.signAsync(
|
||||
{
|
||||
purpose: 'media-stream',
|
||||
objectKey: storageKey,
|
||||
ownerId,
|
||||
roomId: options?.roomId,
|
||||
fileName: options?.fileName
|
||||
} satisfies MediaStreamPayload,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
||||
issuer: 'id.lendry.ru'
|
||||
}
|
||||
);
|
||||
const publicApiUrl = this.config.get<string>('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, '');
|
||||
return {
|
||||
accessUrl: `${publicApiUrl}/media/stream/${token}`,
|
||||
expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async assertFamilyMember(groupId: string, userId: string) {
|
||||
const member = await this.prisma.familyMember.findFirst({ where: { groupId, userId } });
|
||||
if (!member) {
|
||||
throw new ForbiddenException('Нет доступа к семье');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertChatMember(roomId: string, userId: string) {
|
||||
const member = await this.prisma.chatRoomMember.findFirst({ where: { roomId, userId } });
|
||||
if (!member) {
|
||||
throw new ForbiddenException('Нет доступа к чату');
|
||||
}
|
||||
}
|
||||
}
|
||||
166
apps/sso-core/src/domain/notifications.service.ts
Normal file
166
apps/sso-core/src/domain/notifications.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { NotificationPublisherService } from '../infra/notification-publisher.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly publisher: NotificationPublisherService
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
type: string,
|
||||
title: string,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
options?: { skipPublish?: boolean }
|
||||
) {
|
||||
const notification = await this.prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
payload: (payload ?? undefined) as never
|
||||
}
|
||||
});
|
||||
|
||||
if (!options?.skipPublish) {
|
||||
await this.publisher.publish({
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
payload: {
|
||||
notificationId: notification.id,
|
||||
...(payload ?? {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return this.toResponse(notification);
|
||||
}
|
||||
|
||||
async publishRealtime(userId: string, type: string, title: string, message: string, payload?: Record<string, unknown>) {
|
||||
await this.publisher.publish({ userId, type, title, message, payload });
|
||||
}
|
||||
|
||||
async list(userId: string, limit = 30, unreadOnly = false) {
|
||||
const notifications = await this.prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(unreadOnly ? { isRead: false } : {})
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(Math.max(limit, 1), 100)
|
||||
});
|
||||
|
||||
const inviteIds = notifications
|
||||
.filter((item) => item.type === 'family_invite')
|
||||
.map((item) => (item.payload as { inviteId?: string } | null)?.inviteId)
|
||||
.filter((inviteId): inviteId is string => Boolean(inviteId));
|
||||
|
||||
const pendingInviteIds = new Set<string>();
|
||||
if (inviteIds.length) {
|
||||
const invites = await this.prisma.familyInvite.findMany({
|
||||
where: { id: { in: inviteIds }, status: 'PENDING' }
|
||||
});
|
||||
invites.forEach((invite) => pendingInviteIds.add(invite.id));
|
||||
}
|
||||
|
||||
const staleIds: string[] = [];
|
||||
const filtered = notifications.filter((item) => {
|
||||
if (item.type !== 'family_invite') return true;
|
||||
const inviteId = (item.payload as { inviteId?: string } | null)?.inviteId;
|
||||
if (!inviteId || !pendingInviteIds.has(inviteId)) {
|
||||
staleIds.push(item.id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (staleIds.length) {
|
||||
await this.prisma.notification.deleteMany({
|
||||
where: { id: { in: staleIds }, userId }
|
||||
});
|
||||
}
|
||||
|
||||
return { notifications: filtered.map((item) => this.toResponse(item)) };
|
||||
}
|
||||
|
||||
async unreadCount(userId: string) {
|
||||
const count = await this.prisma.notification.count({
|
||||
where: { userId, isRead: false }
|
||||
});
|
||||
return { count };
|
||||
}
|
||||
|
||||
async markRead(userId: string, notificationId: string) {
|
||||
return this.delete(userId, notificationId);
|
||||
}
|
||||
|
||||
async markAllRead(userId: string) {
|
||||
return this.deleteAll(userId);
|
||||
}
|
||||
|
||||
async delete(userId: string, notificationId: string) {
|
||||
const existing = await this.prisma.notification.findFirst({
|
||||
where: { id: notificationId, userId }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Уведомление не найдено');
|
||||
}
|
||||
await this.prisma.notification.delete({ where: { id: notificationId } });
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
async deleteAll(userId: string) {
|
||||
const result = await this.prisma.notification.deleteMany({ where: { userId } });
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
async dismissFamilyInvite(userId: string, inviteId: string) {
|
||||
const items = await this.prisma.notification.findMany({
|
||||
where: { userId, type: 'family_invite' }
|
||||
});
|
||||
const ids = items
|
||||
.filter((item) => {
|
||||
const payload = item.payload as { inviteId?: string } | null;
|
||||
return payload?.inviteId === inviteId;
|
||||
})
|
||||
.map((item) => item.id);
|
||||
|
||||
if (!ids.length) {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
await this.prisma.notification.deleteMany({
|
||||
where: { id: { in: ids }, userId }
|
||||
});
|
||||
return { count: ids.length };
|
||||
}
|
||||
|
||||
private toResponse(notification: {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
payload: unknown;
|
||||
isRead: boolean;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: notification.id,
|
||||
userId: notification.userId,
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
payloadJson: notification.payload ? JSON.stringify(notification.payload) : '',
|
||||
isRead: notification.isRead,
|
||||
createdAt: notification.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
78
apps/sso-core/src/domain/oauth-core.service.ts
Normal file
78
apps/sso-core/src/domain/oauth-core.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthCoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
|
||||
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
||||
if (!client?.isActive || !client.redirectUris.includes(command.redirectUri)) {
|
||||
throw new BadRequestException('OAuth приложение не найдено или redirect_uri запрещен');
|
||||
}
|
||||
|
||||
const code = randomBytes(32).toString('base64url');
|
||||
await this.prisma.oAuthAuthorizationCode.create({
|
||||
data: {
|
||||
code,
|
||||
userId: command.userId,
|
||||
clientId: client.id,
|
||||
redirectUri: command.redirectUri,
|
||||
scopes: command.scope.split(' ').filter(Boolean),
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000)
|
||||
}
|
||||
});
|
||||
|
||||
const url = new URL(command.redirectUri);
|
||||
url.searchParams.set('code', code);
|
||||
if (command.state) url.searchParams.set('state', command.state);
|
||||
return { redirectUrl: url.toString(), code, state: command.state };
|
||||
}
|
||||
|
||||
async token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) {
|
||||
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
||||
if (!client?.isActive) throw new UnauthorizedException('OAuth приложение не найдено');
|
||||
if (client.clientSecret && client.clientSecret !== command.clientSecret) throw new UnauthorizedException('Неверный client_secret');
|
||||
|
||||
if (command.grantType === 'authorization_code') {
|
||||
if (!command.code || !command.redirectUri) throw new BadRequestException('Передайте code и redirect_uri');
|
||||
const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({ where: { code: command.code }, include: { user: true } });
|
||||
if (!authCode || authCode.consumedAt || authCode.expiresAt < new Date() || authCode.redirectUri !== command.redirectUri || authCode.clientId !== client.id) {
|
||||
throw new UnauthorizedException('Код авторизации недействителен');
|
||||
}
|
||||
await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } });
|
||||
return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes);
|
||||
}
|
||||
|
||||
if (command.grantType === 'refresh_token') {
|
||||
if (!command.refreshToken) throw new BadRequestException('Передайте refresh_token');
|
||||
const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(command.refreshToken, { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') });
|
||||
if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) throw new UnauthorizedException('Refresh token недействителен');
|
||||
return this.issueOAuthTokens(payload.sub, client.clientId, payload.scopes ?? []);
|
||||
}
|
||||
|
||||
throw new BadRequestException('Неподдерживаемый grant_type');
|
||||
}
|
||||
|
||||
async userInfo(accessToken: string) {
|
||||
const payload = await this.jwt.verifyAsync<{ sub: string }>(accessToken, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET') });
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||
return { sub: user.id, email: user.email, phone: user.phone, name: user.displayName, picture: user.avatarUrl };
|
||||
}
|
||||
|
||||
private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) {
|
||||
const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' });
|
||||
const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer: 'id.lendry.ru' });
|
||||
const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' });
|
||||
return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken };
|
||||
}
|
||||
}
|
||||
49
apps/sso-core/src/domain/otp.service.ts
Normal file
49
apps/sso-core/src/domain/otp.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: SettingsService
|
||||
) {}
|
||||
|
||||
async send(command: { target: string; channel: string; purpose: string; userId?: string }) {
|
||||
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
|
||||
const code = String(randomInt(100000, 999999));
|
||||
const expiresAt = new Date(Date.now() + expiryMinutes * 60_000);
|
||||
await this.prisma.authCode.create({
|
||||
data: {
|
||||
userId: command.userId,
|
||||
target: command.target,
|
||||
channel: command.channel,
|
||||
purpose: command.purpose,
|
||||
codeHash: await bcrypt.hash(code, 10),
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
console.log(`OTP ${command.channel} для ${command.target}: ${code}`);
|
||||
return { sent: true, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
async verify(command: { target: string; code: string; purpose: string }) {
|
||||
const authCode = await this.prisma.authCode.findFirst({
|
||||
where: {
|
||||
target: command.target,
|
||||
purpose: command.purpose,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (!authCode) throw new BadRequestException('Код подтверждения не найден или истек');
|
||||
const matches = await bcrypt.compare(command.code, authCode.codeHash);
|
||||
if (!matches) throw new UnauthorizedException('Неверный код подтверждения');
|
||||
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
|
||||
return { verified: true };
|
||||
}
|
||||
}
|
||||
185
apps/sso-core/src/domain/pin.service.ts
Normal file
185
apps/sso-core/src/domain/pin.service.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { SessionStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Injectable()
|
||||
export class PinService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly session: SessionService
|
||||
) {}
|
||||
|
||||
private async getPinLengthBounds() {
|
||||
const min = await this.settings.getNumber('PIN_MIN_LENGTH', 4);
|
||||
const max = await this.settings.getNumber('PIN_MAX_LENGTH', 6);
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
private async assertValidPinFormat(pin: string) {
|
||||
const { min, max } = await this.getPinLengthBounds();
|
||||
const pattern = new RegExp(`^\\d{${min},${max}}$`);
|
||||
if (!pattern.test(pin)) {
|
||||
throw new BadRequestException(`PIN-код должен содержать от ${min} до ${max} цифр`);
|
||||
}
|
||||
}
|
||||
|
||||
async enablePin(userId: string, pin: string) {
|
||||
await this.assertValidPinFormat(pin);
|
||||
|
||||
return this.prisma.pinCode.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
hash: await bcrypt.hash(pin, 12),
|
||||
isEnabled: true,
|
||||
deletionRequestedAt: null
|
||||
},
|
||||
update: {
|
||||
hash: await bcrypt.hash(pin, 12),
|
||||
isEnabled: true,
|
||||
deletionRequestedAt: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setupPin(userId: string, pin: string) {
|
||||
const pinCode = await this.enablePin(userId, pin);
|
||||
return { userId: pinCode.userId, isEnabled: pinCode.isEnabled, deletionRequestedAt: pinCode.deletionRequestedAt?.toISOString() };
|
||||
}
|
||||
|
||||
async updatePin(userId: string, pin: string) {
|
||||
const pinCode = await this.enablePin(userId, pin);
|
||||
await this.prisma.session.updateMany({
|
||||
where: { userId, status: SessionStatus.ACTIVE },
|
||||
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
||||
});
|
||||
return { userId: pinCode.userId, isEnabled: pinCode.isEnabled, deletionRequestedAt: pinCode.deletionRequestedAt?.toISOString() };
|
||||
}
|
||||
|
||||
async requestPinDeletion(userId: string, pin?: string) {
|
||||
const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } });
|
||||
if (!pinCode?.isEnabled) {
|
||||
throw new BadRequestException('PIN-код не включен');
|
||||
}
|
||||
|
||||
const requirePin = await this.settings.getBoolean('PIN_REQUIRE_ON_DELETE', true);
|
||||
if (requirePin) {
|
||||
if (!pin) {
|
||||
throw new BadRequestException('Для удаления PIN-кода требуется подтверждение текущим PIN');
|
||||
}
|
||||
const matches = await bcrypt.compare(pin, pinCode.hash);
|
||||
if (!matches) {
|
||||
throw new UnauthorizedException('Неверный PIN-код');
|
||||
}
|
||||
}
|
||||
|
||||
const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440);
|
||||
const deletionRequestedAt = new Date();
|
||||
const effectiveAt = new Date(deletionRequestedAt.getTime() + graceMinutes * 60_000);
|
||||
|
||||
await this.prisma.pinCode.update({
|
||||
where: { userId },
|
||||
data: { deletionRequestedAt }
|
||||
});
|
||||
|
||||
return {
|
||||
userId,
|
||||
deletionRequestedAt: deletionRequestedAt.toISOString(),
|
||||
effectiveAt: effectiveAt.toISOString(),
|
||||
graceMinutes
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPinDeletion(userId: string) {
|
||||
const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } });
|
||||
if (!pinCode) {
|
||||
throw new BadRequestException('PIN-код не найден');
|
||||
}
|
||||
await this.prisma.pinCode.update({
|
||||
where: { userId },
|
||||
data: { deletionRequestedAt: null }
|
||||
});
|
||||
return { userId, cancelled: true };
|
||||
}
|
||||
|
||||
async finalizeDuePinDeletions() {
|
||||
const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440);
|
||||
const threshold = new Date(Date.now() - graceMinutes * 60_000);
|
||||
const due = await this.prisma.pinCode.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
deletionRequestedAt: { lte: threshold }
|
||||
}
|
||||
});
|
||||
|
||||
for (const pinCode of due) {
|
||||
await this.disablePin(pinCode.userId, { skipGraceCheck: true });
|
||||
}
|
||||
|
||||
return { count: due.length };
|
||||
}
|
||||
|
||||
async disablePin(userId: string, options?: { skipGraceCheck?: boolean }) {
|
||||
const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } });
|
||||
if (!pinCode?.isEnabled) {
|
||||
throw new BadRequestException('PIN-код не включен');
|
||||
}
|
||||
|
||||
if (!options?.skipGraceCheck && pinCode.deletionRequestedAt) {
|
||||
const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440);
|
||||
const effectiveAt = pinCode.deletionRequestedAt.getTime() + graceMinutes * 60_000;
|
||||
if (Date.now() < effectiveAt) {
|
||||
throw new BadRequestException('PIN-код будет удалён после истечения периода ожидания');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.pinCode.update({
|
||||
where: { userId },
|
||||
data: { isEnabled: false, deletionRequestedAt: null }
|
||||
});
|
||||
}
|
||||
|
||||
async verifyPin(sessionId: string, pin: string) {
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: { user: { include: { pinCode: true } } }
|
||||
});
|
||||
|
||||
if (!session || session.status === SessionStatus.REVOKED || session.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('Сессия недействительна');
|
||||
}
|
||||
|
||||
if (!session.user.pinCode?.isEnabled) {
|
||||
throw new BadRequestException('PIN-код не включен');
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(pin, session.user.pinCode.hash);
|
||||
if (!matches) {
|
||||
throw new UnauthorizedException('Неверный PIN-код');
|
||||
}
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
pinVerified: true,
|
||||
status: SessionStatus.ACTIVE,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: await this.auth.issueAccessToken(session.user, session.id, true),
|
||||
pinVerified: true
|
||||
};
|
||||
}
|
||||
|
||||
async lockExpiredPinSessions() {
|
||||
return this.session.lockExpiredPinSessions();
|
||||
}
|
||||
}
|
||||
441
apps/sso-core/src/domain/profile.service.ts
Normal file
441
apps/sso-core/src/domain/profile.service.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
import { SessionStatus, UserStatus } from '../generated/prisma/client';
|
||||
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
|
||||
|
||||
interface UpdateProfileCommand {
|
||||
|
||||
userId: string;
|
||||
|
||||
firstName?: string;
|
||||
|
||||
lastName?: string;
|
||||
|
||||
bio?: string;
|
||||
|
||||
age?: number;
|
||||
|
||||
gender?: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface UpdateContactsCommand {
|
||||
|
||||
userId: string;
|
||||
|
||||
email?: string;
|
||||
|
||||
phone?: string;
|
||||
|
||||
backupEmail?: string;
|
||||
|
||||
backupPhone?: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildDeletedFieldValue(userId: string, value: string): string {
|
||||
|
||||
const prefix = `is_deleted_${userId}_`;
|
||||
|
||||
if (value.startsWith(prefix)) return value;
|
||||
|
||||
return `${prefix}${value}`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function prefixNullable(userId: string, value: string | null | undefined): string | null {
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
return buildDeletedFieldValue(userId, value);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
||||
export class ProfileService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
|
||||
|
||||
async getProfile(userId: string) {
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
include: { profile: true }
|
||||
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
|
||||
}
|
||||
|
||||
return this.toResponse(user);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async updateAvatar(userId: string, avatarUrl?: string, avatarStorageKey?: string) {
|
||||
|
||||
if (!avatarStorageKey && !avatarUrl) {
|
||||
|
||||
throw new BadRequestException('Укажите ключ хранилища или ссылку на аватар');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (avatarStorageKey) {
|
||||
|
||||
if (!avatarStorageKey.startsWith(`avatars/${userId}/`)) {
|
||||
|
||||
throw new BadRequestException('Некорректный ключ аватара');
|
||||
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
data: { avatarStorageKey, avatarUrl: null },
|
||||
|
||||
include: { profile: true }
|
||||
|
||||
});
|
||||
|
||||
return this.toResponse(user);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!avatarUrl || !/^https?:\/\/.+/i.test(avatarUrl)) {
|
||||
|
||||
throw new BadRequestException('Укажите корректную ссылку на аватар или ключ хранилища');
|
||||
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
data: { avatarUrl, avatarStorageKey: null },
|
||||
|
||||
include: { profile: true }
|
||||
|
||||
});
|
||||
|
||||
return this.toResponse(user);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async updateProfile(command: UpdateProfileCommand) {
|
||||
|
||||
const displayName = [command.firstName, command.lastName].filter(Boolean).join(' ').trim();
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
|
||||
where: { id: command.userId },
|
||||
|
||||
data: {
|
||||
|
||||
displayName: displayName || undefined,
|
||||
|
||||
gender: command.gender,
|
||||
|
||||
profile: {
|
||||
|
||||
upsert: {
|
||||
|
||||
create: {
|
||||
|
||||
firstName: command.firstName,
|
||||
|
||||
lastName: command.lastName,
|
||||
|
||||
bio: command.bio,
|
||||
|
||||
age: command.age,
|
||||
|
||||
gender: command.gender
|
||||
|
||||
},
|
||||
|
||||
update: {
|
||||
|
||||
firstName: command.firstName,
|
||||
|
||||
lastName: command.lastName,
|
||||
|
||||
bio: command.bio,
|
||||
|
||||
age: command.age,
|
||||
|
||||
gender: command.gender
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
include: { profile: true }
|
||||
|
||||
});
|
||||
|
||||
return this.toResponse(user);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async updateContacts(command: UpdateContactsCommand) {
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
|
||||
where: { id: command.userId },
|
||||
|
||||
data: {
|
||||
|
||||
email: command.email,
|
||||
|
||||
phone: command.phone,
|
||||
|
||||
backupEmail: command.backupEmail,
|
||||
|
||||
backupPhone: command.backupPhone
|
||||
|
||||
},
|
||||
|
||||
include: { profile: true }
|
||||
|
||||
});
|
||||
|
||||
return this.toResponse(user);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async setPassword(userId: string, password: string) {
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
|
||||
throw new BadRequestException('Пароль должен содержать минимум 8 символов');
|
||||
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
data: { passwordHash: await bcrypt.hash(password, 12) }
|
||||
|
||||
});
|
||||
|
||||
return { hasPassword: Boolean(user.passwordHash) };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async softDeleteProfile(userId: string) {
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
include: { profile: true, linkedAccounts: true }
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (!user) {
|
||||
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (user.status === UserStatus.DELETED || user.deletedAt) {
|
||||
|
||||
throw new BadRequestException('Профиль уже удалён');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
await tx.session.updateMany({
|
||||
|
||||
where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
|
||||
|
||||
data: { status: SessionStatus.REVOKED }
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await tx.userRole.deleteMany({ where: { userId } });
|
||||
|
||||
|
||||
|
||||
await tx.user.update({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
data: {
|
||||
|
||||
email: prefixNullable(userId, user.email),
|
||||
|
||||
phone: prefixNullable(userId, user.phone),
|
||||
|
||||
backupEmail: prefixNullable(userId, user.backupEmail),
|
||||
|
||||
backupPhone: prefixNullable(userId, user.backupPhone),
|
||||
|
||||
username: prefixNullable(userId, user.username),
|
||||
|
||||
displayName: buildDeletedFieldValue(userId, user.displayName),
|
||||
|
||||
avatarUrl: null,
|
||||
|
||||
avatarStorageKey: null,
|
||||
|
||||
passwordHash: null,
|
||||
|
||||
isSuperAdmin: false,
|
||||
|
||||
status: UserStatus.DELETED,
|
||||
|
||||
deletedAt: new Date()
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (user.profile) {
|
||||
|
||||
await tx.userProfile.update({
|
||||
|
||||
where: { userId },
|
||||
|
||||
data: {
|
||||
|
||||
firstName: prefixNullable(userId, user.profile.firstName),
|
||||
|
||||
lastName: prefixNullable(userId, user.profile.lastName),
|
||||
|
||||
bio: prefixNullable(userId, user.profile.bio)
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
for (const account of user.linkedAccounts) {
|
||||
|
||||
await tx.linkedAccount.update({
|
||||
|
||||
where: { id: account.id },
|
||||
|
||||
data: {
|
||||
|
||||
providerId: buildDeletedFieldValue(userId, account.providerId),
|
||||
|
||||
email: prefixNullable(userId, account.email),
|
||||
|
||||
displayName: account.displayName ? prefixNullable(userId, account.displayName) : null
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return { success: true };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private toResponse(user: Awaited<ReturnType<PrismaService['user']['findUnique']>> & { profile?: { id?: string; firstName?: string | null; lastName?: string | null; bio?: string | null; age?: number | null; gender?: string | null } | null }) {
|
||||
|
||||
if (!user) {
|
||||
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
id: user.profile?.id ?? user.id,
|
||||
|
||||
userId: user.id,
|
||||
|
||||
displayName: user.displayName,
|
||||
|
||||
avatarUrl: user.avatarUrl,
|
||||
|
||||
hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl),
|
||||
|
||||
firstName: user.profile?.firstName,
|
||||
|
||||
lastName: user.profile?.lastName,
|
||||
|
||||
bio: user.profile?.bio,
|
||||
|
||||
age: user.profile?.age,
|
||||
|
||||
gender: user.profile?.gender ?? user.gender,
|
||||
|
||||
email: user.email,
|
||||
|
||||
phone: user.phone,
|
||||
|
||||
backupEmail: user.backupEmail,
|
||||
|
||||
backupPhone: user.backupPhone
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
203
apps/sso-core/src/domain/rbac.service.ts
Normal file
203
apps/sso-core/src/domain/rbac.service.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OAuthClientType } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Injectable()
|
||||
export class RbacService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: AccessService
|
||||
) {}
|
||||
|
||||
async createRole(actorUserId: string, data: { slug: string; name: string; description?: string; permissionSlugs?: string[] }) {
|
||||
await this.access.assertSuperAdmin(actorUserId);
|
||||
return this.prisma.role.create({
|
||||
data: {
|
||||
slug: data.slug,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
permissions: data.permissionSlugs?.length
|
||||
? {
|
||||
create: data.permissionSlugs.map((slug) => ({
|
||||
permission: { connect: { slug } }
|
||||
}))
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
include: { permissions: { include: { permission: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
async upsertPermission(data: { slug: string; name: string; description?: string }) {
|
||||
return this.prisma.permission.upsert({
|
||||
where: { slug: data.slug },
|
||||
create: data,
|
||||
update: { name: data.name, description: data.description }
|
||||
});
|
||||
}
|
||||
|
||||
async assignRole(actorUserId: string, userId: string, roleSlug: string) {
|
||||
await this.access.assertSuperAdmin(actorUserId);
|
||||
const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } });
|
||||
if (!role) {
|
||||
throw new NotFoundException('Роль не найдена');
|
||||
}
|
||||
await this.ensureUserExists(userId);
|
||||
await this.prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId, roleId: role.id } },
|
||||
create: { userId, roleId: role.id },
|
||||
update: {}
|
||||
});
|
||||
return this.getUserRoleSlugs(userId);
|
||||
}
|
||||
|
||||
async removeRole(actorUserId: string, userId: string, roleSlug: string) {
|
||||
await this.access.assertSuperAdmin(actorUserId);
|
||||
const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } });
|
||||
if (!role) {
|
||||
throw new NotFoundException('Роль не найдена');
|
||||
}
|
||||
await this.prisma.userRole.deleteMany({ where: { userId, roleId: role.id } });
|
||||
return this.getUserRoleSlugs(userId);
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
return this.prisma.role.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { permissions: { include: { permission: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
async listPermissions() {
|
||||
return this.prisma.permission.findMany({ orderBy: { slug: 'asc' } });
|
||||
}
|
||||
|
||||
async listOAuthScopes() {
|
||||
return this.prisma.oAuthScope.findMany({ orderBy: { slug: 'asc' } });
|
||||
}
|
||||
|
||||
async createOAuthClient(actorUserId: string, data: { name: string; redirectUris: string[]; scopes: string[]; type?: OAuthClientType }) {
|
||||
await this.assertOAuthPermission(actorUserId);
|
||||
if (!data.redirectUris.length) {
|
||||
throw new BadRequestException('Укажите хотя бы один redirect URI');
|
||||
}
|
||||
if (!data.scopes.length) {
|
||||
throw new BadRequestException('Выберите хотя бы один scope');
|
||||
}
|
||||
|
||||
const clientSecret = data.type === OAuthClientType.PUBLIC ? null : randomBytes(32).toString('base64url');
|
||||
const client = await this.prisma.oAuthClient.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
clientId: `lendry_${randomBytes(16).toString('hex')}`,
|
||||
clientSecret,
|
||||
redirectUris: data.redirectUris,
|
||||
type: data.type ?? OAuthClientType.CONFIDENTIAL,
|
||||
scopes: {
|
||||
create: data.scopes.map((slug) => ({
|
||||
scope: { connect: { slug } }
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { scopes: { include: { scope: true } } }
|
||||
});
|
||||
|
||||
return { client, clientSecret };
|
||||
}
|
||||
|
||||
async updateOAuthClient(
|
||||
actorUserId: string,
|
||||
clientId: string,
|
||||
data: { name?: string; redirectUris?: string[]; scopes?: string[]; isActive?: boolean }
|
||||
) {
|
||||
await this.assertOAuthPermission(actorUserId);
|
||||
const existing = await this.prisma.oAuthClient.findUnique({
|
||||
where: { clientId },
|
||||
include: { scopes: true }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('OAuth-приложение не найдено');
|
||||
}
|
||||
|
||||
if (data.redirectUris && !data.redirectUris.length) {
|
||||
throw new BadRequestException('Укажите хотя бы один redirect URI');
|
||||
}
|
||||
|
||||
if (data.scopes) {
|
||||
await this.prisma.oAuthClientScope.deleteMany({ where: { clientId: existing.id } });
|
||||
for (const slug of data.scopes) {
|
||||
const scope = await this.prisma.oAuthScope.findUnique({ where: { slug } });
|
||||
if (!scope) continue;
|
||||
await this.prisma.oAuthClientScope.create({
|
||||
data: { clientId: existing.id, scopeId: scope.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.oAuthClient.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
redirectUris: data.redirectUris,
|
||||
isActive: data.isActive
|
||||
},
|
||||
include: { scopes: { include: { scope: true } } }
|
||||
});
|
||||
|
||||
return { client: updated, clientSecret: undefined as string | undefined };
|
||||
}
|
||||
|
||||
async rotateOAuthSecret(actorUserId: string, clientId: string) {
|
||||
await this.assertOAuthPermission(actorUserId);
|
||||
const existing = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException('OAuth-приложение не найдено');
|
||||
}
|
||||
if (existing.type === OAuthClientType.PUBLIC) {
|
||||
throw new BadRequestException('У публичного клиента нет client secret');
|
||||
}
|
||||
|
||||
const clientSecret = randomBytes(32).toString('base64url');
|
||||
await this.prisma.oAuthClient.update({
|
||||
where: { id: existing.id },
|
||||
data: { clientSecret }
|
||||
});
|
||||
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
async listOAuthClients() {
|
||||
return this.prisma.oAuthClient.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { scopes: { include: { scope: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
private async assertOAuthPermission(actorUserId: string) {
|
||||
const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } });
|
||||
if (!actor) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
const access = await this.access.getUserAccess(actor.id, actor.isSuperAdmin);
|
||||
if (!access.canManageOAuth) {
|
||||
throw new ForbiddenException('Недостаточно прав для управления OAuth-приложениями');
|
||||
}
|
||||
}
|
||||
|
||||
private async getUserRoleSlugs(userId: string) {
|
||||
const links = await this.prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: { role: true }
|
||||
});
|
||||
return links.map((link) => link.role.slug);
|
||||
}
|
||||
|
||||
private async ensureUserExists(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
}
|
||||
}
|
||||
87
apps/sso-core/src/domain/security.service.ts
Normal file
87
apps/sso-core/src/domain/security.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SessionStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SecurityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listActiveDevices(userId: string) {
|
||||
return this.prisma.device.findMany({
|
||||
where: { userId },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
include: {
|
||||
sessions: {
|
||||
where: { status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listActiveSessions(userId: string) {
|
||||
return this.prisma.session.findMany({
|
||||
where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { device: true }
|
||||
});
|
||||
}
|
||||
|
||||
async listSignInHistory(userId: string) {
|
||||
return this.prisma.signInEvent.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
include: { device: true }
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSession(userId: string, sessionId: string) {
|
||||
return this.prisma.session.update({
|
||||
where: { id: sessionId, userId },
|
||||
data: { status: SessionStatus.REVOKED }
|
||||
});
|
||||
}
|
||||
|
||||
async lockSession(userId: string, sessionId: string) {
|
||||
return this.prisma.session.update({
|
||||
where: { id: sessionId, userId },
|
||||
data: { status: SessionStatus.LOCKED, pinVerified: false }
|
||||
});
|
||||
}
|
||||
|
||||
async revokeDevice(userId: string, deviceId: string) {
|
||||
return this.prisma.session.updateMany({
|
||||
where: { userId, deviceId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
|
||||
data: { status: SessionStatus.REVOKED }
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAllSessions(userId: string) {
|
||||
return this.prisma.session.updateMany({
|
||||
where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
|
||||
data: { status: SessionStatus.REVOKED }
|
||||
});
|
||||
}
|
||||
|
||||
async connectSocialAccount(userId: string, data: { providerName: string; providerId: string; email?: string; displayName?: string; avatarUrl?: string }) {
|
||||
return this.prisma.linkedAccount.upsert({
|
||||
where: { providerName_providerId: { providerName: data.providerName, providerId: data.providerId } },
|
||||
create: { userId, ...data },
|
||||
update: { email: data.email, displayName: data.displayName, avatarUrl: data.avatarUrl }
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectSocialAccount(userId: string, providerName: string) {
|
||||
return this.prisma.linkedAccount.deleteMany({
|
||||
where: { userId, providerName }
|
||||
});
|
||||
}
|
||||
|
||||
async listLinkedAccounts(userId: string) {
|
||||
return this.prisma.linkedAccount.findMany({
|
||||
where: { userId },
|
||||
orderBy: { providerName: 'asc' }
|
||||
});
|
||||
}
|
||||
}
|
||||
164
apps/sso-core/src/domain/session.service.ts
Normal file
164
apps/sso-core/src/domain/session.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { SessionStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
export interface SessionState {
|
||||
id: string;
|
||||
userId: string;
|
||||
pinVerified: boolean;
|
||||
status: SessionStatus;
|
||||
requiresPin: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly auth: AuthService
|
||||
) {}
|
||||
|
||||
async resolveSessionState(sessionId: string): Promise<SessionState | null> {
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: { user: { include: { pinCode: true } } }
|
||||
});
|
||||
|
||||
if (!session || session.status === SessionStatus.REVOKED || session.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pinEnabled = Boolean(session.user.pinCode?.isEnabled);
|
||||
let pinVerified = session.pinVerified;
|
||||
let status = session.status;
|
||||
|
||||
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
|
||||
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
||||
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
||||
if (session.updatedAt < threshold) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
||||
});
|
||||
pinVerified = false;
|
||||
status = SessionStatus.LOCKED;
|
||||
}
|
||||
}
|
||||
|
||||
const requiresPin = pinEnabled && (!pinVerified || status === SessionStatus.LOCKED);
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
pinVerified,
|
||||
status,
|
||||
requiresPin
|
||||
};
|
||||
}
|
||||
|
||||
async touchSessionActivity(sessionId: string) {
|
||||
const state = await this.resolveSessionState(sessionId);
|
||||
if (!state || state.requiresPin) {
|
||||
return state;
|
||||
}
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async validateSession(userId: string, sessionId: string, touchActivity: boolean) {
|
||||
const state = touchActivity
|
||||
? await this.touchSessionActivity(sessionId)
|
||||
: await this.resolveSessionState(sessionId);
|
||||
|
||||
if (!state || state.userId !== userId) {
|
||||
throw new UnauthorizedException('Сессия недействительна');
|
||||
}
|
||||
|
||||
return {
|
||||
requiresPin: state.requiresPin,
|
||||
sessionId: state.id,
|
||||
pinVerified: state.pinVerified
|
||||
};
|
||||
}
|
||||
|
||||
async refreshSession(refreshToken: string, sessionId?: string) {
|
||||
const sessions = await this.prisma.session.findMany({
|
||||
where: {
|
||||
...(sessionId ? { id: sessionId } : {}),
|
||||
status: { not: SessionStatus.REVOKED },
|
||||
expiresAt: { gt: new Date() }
|
||||
},
|
||||
include: { user: { include: { pinCode: true } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: sessionId ? 1 : 50
|
||||
});
|
||||
|
||||
let matchedSession: (typeof sessions)[number] | null = null;
|
||||
for (const session of sessions) {
|
||||
const matches = await bcrypt.compare(refreshToken, session.refreshHash);
|
||||
if (matches) {
|
||||
matchedSession = session;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedSession) {
|
||||
throw new UnauthorizedException('Refresh token недействителен');
|
||||
}
|
||||
|
||||
const state = await this.resolveSessionState(matchedSession.id);
|
||||
if (!state) {
|
||||
throw new UnauthorizedException('Сессия недействительна');
|
||||
}
|
||||
|
||||
if (state.requiresPin) {
|
||||
const user = await this.auth.getMe(matchedSession.userId);
|
||||
return {
|
||||
requiresPin: true,
|
||||
sessionId: state.id,
|
||||
pinVerified: false,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: matchedSession.id },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
const user = await this.auth.getMe(matchedSession.userId);
|
||||
|
||||
return {
|
||||
requiresPin: false,
|
||||
accessToken: await this.auth.issueAccessToken(matchedSession.user, matchedSession.id, true),
|
||||
sessionId: matchedSession.id,
|
||||
pinVerified: true,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
async lockExpiredPinSessions() {
|
||||
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
||||
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
||||
return this.prisma.session.updateMany({
|
||||
where: {
|
||||
pinVerified: true,
|
||||
updatedAt: { lt: threshold },
|
||||
status: SessionStatus.ACTIVE,
|
||||
user: { pinCode: { isEnabled: true } }
|
||||
},
|
||||
data: {
|
||||
pinVerified: false,
|
||||
status: SessionStatus.LOCKED
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
85
apps/sso-core/src/domain/settings.service.ts
Normal file
85
apps/sso-core/src/domain/settings.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { UpsertSettingCommand } from './dto';
|
||||
import { PUBLIC_SETTING_KEYS } from './system-settings.seed';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getValue(key: string, fallback: string): Promise<string> {
|
||||
const setting = await this.prisma.systemSetting.findUnique({ where: { key } });
|
||||
return setting?.value ?? fallback;
|
||||
}
|
||||
|
||||
async getNumber(key: string, fallback: number): Promise<number> {
|
||||
const raw = await this.getValue(key, String(fallback));
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
async getBoolean(key: string, fallback: boolean): Promise<boolean> {
|
||||
const raw = (await this.getValue(key, fallback ? 'true' : 'false')).trim().toLowerCase();
|
||||
if (raw === 'true' || raw === '1' || raw === 'yes') return true;
|
||||
if (raw === 'false' || raw === '0' || raw === 'no') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async listPublic() {
|
||||
return this.prisma.systemSetting.findMany({
|
||||
where: { key: { in: [...PUBLIC_SETTING_KEYS] }, isSecret: false },
|
||||
orderBy: { key: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
return this.prisma.systemSetting.findMany({ orderBy: { key: 'asc' } });
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
return this.prisma.systemSetting.findUniqueOrThrow({ where: { key } });
|
||||
}
|
||||
|
||||
async upsert(command: UpsertSettingCommand) {
|
||||
return this.prisma.systemSetting.upsert({
|
||||
where: { key: command.key },
|
||||
create: {
|
||||
key: command.key,
|
||||
value: command.value,
|
||||
description: command.description,
|
||||
isSecret: command.isSecret ?? false
|
||||
},
|
||||
update: {
|
||||
value: command.value,
|
||||
description: command.description,
|
||||
isSecret: command.isSecret ?? false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
await this.prisma.systemSetting.delete({ where: { key } });
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
async listSocialProviders() {
|
||||
return this.prisma.socialProvider.findMany({ orderBy: { providerName: 'asc' } });
|
||||
}
|
||||
|
||||
async upsertSocialProvider(command: { providerName: string; clientId: string; clientSecret?: string; isEnabled: boolean }) {
|
||||
return this.prisma.socialProvider.upsert({
|
||||
where: { providerName: command.providerName },
|
||||
create: command,
|
||||
update: {
|
||||
clientId: command.clientId,
|
||||
clientSecret: command.clientSecret,
|
||||
isEnabled: command.isEnabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSocialProvider(providerName: string) {
|
||||
await this.prisma.socialProvider.delete({ where: { providerName } });
|
||||
return { count: 1 };
|
||||
}
|
||||
}
|
||||
67
apps/sso-core/src/domain/system-settings.seed.ts
Normal file
67
apps/sso-core/src/domain/system-settings.seed.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
export const DEFAULT_SYSTEM_SETTINGS = [
|
||||
{ key: 'PROJECT_NAME', value: 'MVK ID', description: 'Название проекта в интерфейсе и заголовке страницы' },
|
||||
{ key: 'PROJECT_TAGLINE', value: 'Единый аккаунт для сервисов Lendry', description: 'Краткий слоган под названием проекта' },
|
||||
{ key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP' },
|
||||
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', value: '15', description: 'Через сколько минут неактивности сессия блокируется PIN-кодом' },
|
||||
{ key: 'PIN_DELETE_GRACE_MINUTES', value: '1440', description: 'Задержка перед окончательным удалением PIN-кода после запроса (минуты)' },
|
||||
{ key: 'PIN_REQUIRE_ON_DELETE', value: 'true', description: 'Требовать текущий PIN-код при запросе удаления защиты' },
|
||||
{ key: 'PIN_MIN_LENGTH', value: '4', description: 'Минимальная длина PIN-кода' },
|
||||
{ key: 'PIN_MAX_LENGTH', value: '6', description: 'Максимальная длина PIN-кода' },
|
||||
{ key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' },
|
||||
{ key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' },
|
||||
{ key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' },
|
||||
{ key: 'SESSION_REFRESH_DAYS', value: '30', description: 'Срок жизни refresh-токена (дни)' },
|
||||
{ key: 'REGISTRATION_ENABLED', value: 'true', description: 'Разрешить регистрацию новых пользователей' },
|
||||
{ key: 'AVATAR_URL_TTL_MINUTES', value: '15', description: 'Время жизни подписанной ссылки на аватар (минуты)' },
|
||||
{ key: 'PASSWORD_MIN_LENGTH', value: '8', description: 'Минимальная длина пароля' },
|
||||
{ key: 'MAX_ACTIVE_SESSIONS', value: '20', description: 'Максимум одновременных активных сессий на пользователя' },
|
||||
{ key: 'MAINTENANCE_MODE', value: 'false', description: 'Режим обслуживания: блокирует вход для обычных пользователей' },
|
||||
{ key: 'LDAP_ENABLED', value: 'false', description: 'Разрешить вход через LDAP/LDAPS' },
|
||||
{ key: 'LDAP_USE_LDAPS', value: 'false', description: 'Использовать LDAPS (порт 636) вместо LDAP (порт 389)' },
|
||||
{ key: 'LDAP_HOST', value: '', description: 'Хост LDAP: один сервер или несколько через запятую. Можно ldaps://dc-1.local:636' },
|
||||
{ key: 'LDAP_PORT', value: '636', description: 'Порт LDAP-сервера (389 для LDAP, 636 для LDAPS)' },
|
||||
{ key: 'LDAP_DOMAIN', value: '', description: 'Домен AD для UPN bind, например mvkug.local' },
|
||||
{ key: 'LDAP_SKIP_TLS_VERIFY', value: 'false', description: 'Не проверять TLS-сертификат LDAPS (только для тестов)' },
|
||||
{ key: 'LDAP_BASE_DN', value: '', description: 'Base DN для поиска пользователей, например OU=Lugansk,DC=example,DC=com' },
|
||||
{ key: 'LDAP_BIND_USERNAME', value: '', description: 'Логин сервисной учётной записи для bind (например mvkadmin)' },
|
||||
{ key: 'LDAP_BIND_DN', value: '', description: 'Полный Bind DN (опционально). Если указан, имеет приоритет над логином' },
|
||||
{ key: 'LDAP_BIND_PASSWORD', value: '', description: 'Пароль сервисной учётной записи LDAP' },
|
||||
{
|
||||
key: 'LDAP_USER_FILTER',
|
||||
value: '(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))',
|
||||
description: 'LDAP-фильтр поиска пользователя. Используйте {username} как placeholder'
|
||||
},
|
||||
{ key: 'LDAP_USERNAME_ATTR', value: 'sAMAccountName', description: 'Атрибут LDAP с логином пользователя' },
|
||||
{ key: 'LDAP_EMAIL_ATTR', value: 'mail', description: 'Атрибут LDAP с почтой пользователя' },
|
||||
{ key: 'LDAP_DISPLAY_NAME_ATTR', value: 'displayName', description: 'Атрибут LDAP с отображаемым именем' }
|
||||
] as const;
|
||||
|
||||
const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD']);
|
||||
|
||||
export const PUBLIC_SETTING_KEYS = [
|
||||
'PROJECT_NAME',
|
||||
'PROJECT_TAGLINE',
|
||||
'PROJECT_DOMAIN',
|
||||
'REGISTRATION_ENABLED',
|
||||
'MAINTENANCE_MODE',
|
||||
'LDAP_ENABLED',
|
||||
'LDAP_USE_LDAPS'
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SystemSettingsSeedService implements OnModuleInit {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
for (const setting of DEFAULT_SYSTEM_SETTINGS) {
|
||||
await this.prisma.systemSetting.upsert({
|
||||
where: { key: setting.key },
|
||||
create: { ...setting, isSecret: SECRET_SETTING_KEYS.has(setting.key) },
|
||||
update: { description: setting.description, isSecret: SECRET_SETTING_KEYS.has(setting.key) }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user