update
This commit is contained in:
@@ -33,6 +33,7 @@ import { LdapClientService } from './infra/ldap-client.service';
|
||||
import { MessagingService } from './infra/messaging.service';
|
||||
import { SmsService } from './infra/sms.service';
|
||||
import { TotpService } from './domain/totp.service';
|
||||
import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -70,7 +71,8 @@ import { TotpService } from './domain/totp.service';
|
||||
MinioService,
|
||||
LdapClientService,
|
||||
MessagingService,
|
||||
SmsService
|
||||
SmsService,
|
||||
MaintenanceSchedulerService
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -547,6 +547,21 @@ export class AuthGrpcController {
|
||||
return this.profile.softDeleteProfile(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'RequestAccountDeletion')
|
||||
requestAccountDeletion(command: { userId: string }) {
|
||||
return this.profile.requestAccountDeletion(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'CancelAccountDeletion')
|
||||
cancelAccountDeletion(command: { userId: string }) {
|
||||
return this.profile.cancelAccountDeletion(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ProfileService', 'GetAccountDeletionStatus')
|
||||
getAccountDeletionStatus(command: { userId: string }) {
|
||||
return this.profile.getAccountDeletionStatus(command.userId);
|
||||
}
|
||||
|
||||
@GrpcMethod('DocumentsService', 'CreateDocument')
|
||||
createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
|
||||
return this.documents.create(command);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../infra/prisma.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { MinioService } from '../infra/minio.service';
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +23,9 @@ export class FamilyService {
|
||||
|
||||
private readonly settings: SettingsService,
|
||||
|
||||
private readonly notifications: NotificationsService
|
||||
private readonly notifications: NotificationsService,
|
||||
|
||||
private readonly minio: MinioService
|
||||
|
||||
) {}
|
||||
|
||||
@@ -158,12 +161,140 @@ export class FamilyService {
|
||||
|
||||
this.assertOwner(group, requesterId);
|
||||
|
||||
await this.prisma.familyGroup.delete({ where: { id: groupId } });
|
||||
await this.destroyGroupWithCleanup(group);
|
||||
|
||||
return { count: 1 };
|
||||
|
||||
}
|
||||
|
||||
async destroyOwnedGroupsForUser(userId: string) {
|
||||
|
||||
const groups = await this.prisma.familyGroup.findMany({
|
||||
|
||||
where: { ownerId: userId },
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
|
||||
});
|
||||
|
||||
for (const group of groups) {
|
||||
|
||||
await this.destroyGroupWithCleanup(group);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async leaveAllMembershipsForUser(userId: string) {
|
||||
|
||||
const memberships = await this.prisma.familyMember.findMany({
|
||||
|
||||
where: { userId, role: { not: 'owner' } }
|
||||
|
||||
});
|
||||
|
||||
for (const member of memberships) {
|
||||
|
||||
await this.removeMember(userId, member.id);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async destroyGroupWithCleanup(group: {
|
||||
|
||||
id: string;
|
||||
|
||||
ownerId: string;
|
||||
|
||||
name: string;
|
||||
|
||||
avatarStorageKey: string | null;
|
||||
|
||||
members: Array<{ userId: string }>;
|
||||
|
||||
}) {
|
||||
|
||||
const rooms = await this.prisma.chatRoom.findMany({
|
||||
|
||||
where: { groupId: group.id },
|
||||
|
||||
include: { messages: { select: { storageKey: true } } }
|
||||
|
||||
});
|
||||
|
||||
const storageKeys: string[] = [];
|
||||
|
||||
if (group.avatarStorageKey) {
|
||||
|
||||
storageKeys.push(group.avatarStorageKey);
|
||||
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
|
||||
if (room.avatarStorageKey) {
|
||||
|
||||
storageKeys.push(room.avatarStorageKey);
|
||||
|
||||
}
|
||||
|
||||
for (const message of room.messages) {
|
||||
|
||||
if (message.storageKey) {
|
||||
|
||||
storageKeys.push(message.storageKey);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
await this.minio.deleteObjects(storageKeys);
|
||||
|
||||
await this.prisma.familyGroup.delete({ where: { id: group.id } });
|
||||
|
||||
for (const member of group.members) {
|
||||
|
||||
await this.notifications.publishRealtime(
|
||||
|
||||
member.userId,
|
||||
|
||||
'family_group_deleted',
|
||||
|
||||
'Семья удалена',
|
||||
|
||||
`Семья «${group.name}» была удалена`,
|
||||
|
||||
{ groupId: group.id, groupName: group.name }
|
||||
|
||||
);
|
||||
|
||||
if (member.userId === group.ownerId) {
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
await this.notifications.create(
|
||||
|
||||
member.userId,
|
||||
|
||||
'family_group_deleted',
|
||||
|
||||
'Семья удалена',
|
||||
|
||||
`Семья «${group.name}» была удалена создателем`,
|
||||
|
||||
{ groupId: group.id, groupName: group.name }
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async addMember(groupId: string, userId: string, role: string) {
|
||||
|
||||
53
apps/sso-core/src/domain/maintenance-scheduler.service.ts
Normal file
53
apps/sso-core/src/domain/maintenance-scheduler.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PinService } from './pin.service';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
const INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MaintenanceSchedulerService.name);
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly pin: PinService,
|
||||
private readonly profile: ProfileService
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.timer = setInterval(() => {
|
||||
void this.runMaintenance();
|
||||
}, INTERVAL_MS);
|
||||
void this.runMaintenance();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async runMaintenance() {
|
||||
try {
|
||||
const pinResult = await this.pin.finalizeDuePinDeletions();
|
||||
if (pinResult.count > 0) {
|
||||
this.logger.log(`Окончательно удалено PIN-кодов: ${pinResult.count}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Ошибка финализации удаления PIN: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const accountResult = await this.profile.finalizeDueAccountDeletions();
|
||||
if (accountResult.count > 0) {
|
||||
this.logger.log(`Окончательно удалено аккаунтов: ${accountResult.count}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Ошибка финализации удаления аккаунтов: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { SessionStatus, UserStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { OtpService } from './otp.service';
|
||||
import { TotpService } from './totp.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { FamilyService } from './family.service';
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +76,9 @@ export class ProfileService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly otp: OtpService,
|
||||
private readonly totp: TotpService
|
||||
private readonly totp: TotpService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly family: FamilyService
|
||||
) {}
|
||||
|
||||
|
||||
@@ -414,6 +418,96 @@ export class ProfileService {
|
||||
|
||||
|
||||
|
||||
async requestAccountDeletion(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
if (user.status === UserStatus.DELETED || user.deletedAt) {
|
||||
throw new BadRequestException('Профиль уже удалён');
|
||||
}
|
||||
if (user.deletionRequestedAt) {
|
||||
throw new BadRequestException('Удаление аккаунта уже запланировано');
|
||||
}
|
||||
|
||||
const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30);
|
||||
const deletionRequestedAt = new Date();
|
||||
const effectiveAt = new Date(deletionRequestedAt.getTime() + graceDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { deletionRequestedAt }
|
||||
});
|
||||
|
||||
return {
|
||||
userId,
|
||||
deletionRequestedAt: deletionRequestedAt.toISOString(),
|
||||
effectiveAt: effectiveAt.toISOString(),
|
||||
graceDays
|
||||
};
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
if (!user.deletionRequestedAt) {
|
||||
throw new BadRequestException('Запрос на удаление аккаунта не найден');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { deletionRequestedAt: null }
|
||||
});
|
||||
|
||||
return { userId, cancelled: true };
|
||||
}
|
||||
|
||||
async getAccountDeletionStatus(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
|
||||
const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30);
|
||||
if (!user.deletionRequestedAt) {
|
||||
return { pending: false, graceDays };
|
||||
}
|
||||
|
||||
const effectiveAt = new Date(user.deletionRequestedAt.getTime() + graceDays * 24 * 60 * 60 * 1000);
|
||||
return {
|
||||
pending: true,
|
||||
deletionRequestedAt: user.deletionRequestedAt.toISOString(),
|
||||
effectiveAt: effectiveAt.toISOString(),
|
||||
graceDays
|
||||
};
|
||||
}
|
||||
|
||||
async finalizeDueAccountDeletions() {
|
||||
const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30);
|
||||
const threshold = new Date(Date.now() - graceDays * 24 * 60 * 60 * 1000);
|
||||
const due = await this.prisma.user.findMany({
|
||||
where: {
|
||||
status: UserStatus.ACTIVE,
|
||||
deletionRequestedAt: { not: null, lte: threshold }
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
for (const user of due) {
|
||||
await this.finalizeAccountDeletion(user.id);
|
||||
}
|
||||
|
||||
return { count: due.length };
|
||||
}
|
||||
|
||||
async finalizeAccountDeletion(userId: string) {
|
||||
await this.family.destroyOwnedGroupsForUser(userId);
|
||||
await this.family.leaveAllMembershipsForUser(userId);
|
||||
await this.softDeleteProfile(userId);
|
||||
}
|
||||
|
||||
async softDeleteProfile(userId: string) {
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
@@ -486,7 +580,9 @@ export class ProfileService {
|
||||
|
||||
status: UserStatus.DELETED,
|
||||
|
||||
deletedAt: new Date()
|
||||
deletedAt: new Date(),
|
||||
|
||||
deletionRequestedAt: null
|
||||
|
||||
}
|
||||
|
||||
@@ -586,7 +682,9 @@ export class ProfileService {
|
||||
|
||||
birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null,
|
||||
|
||||
hasPassword: Boolean(user.passwordHash)
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
|
||||
deletionRequestedAt: (user as { deletionRequestedAt?: Date | null }).deletionRequestedAt?.toISOString()
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
||||
{ key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' },
|
||||
{ key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' },
|
||||
{ key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' },
|
||||
{
|
||||
key: 'ACCOUNT_DELETE_GRACE_DAYS',
|
||||
value: '30',
|
||||
description: 'Через сколько дней после запроса окончательно удалить аккаунт пользователя'
|
||||
},
|
||||
{ key: 'SESSION_REFRESH_DAYS', value: '30', description: 'Срок жизни refresh-токена (дни)' },
|
||||
{ key: 'REGISTRATION_ENABLED', value: 'true', description: 'Разрешить регистрацию новых пользователей' },
|
||||
{ key: 'AVATAR_URL_TTL_MINUTES', value: '15', description: 'Время жизни подписанной ссылки на аватар (минуты)' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketCorsCommand,
|
||||
PutObjectCommand,
|
||||
@@ -163,4 +164,31 @@ export class MinioService implements OnModuleInit {
|
||||
getBucket() {
|
||||
return this.bucket;
|
||||
}
|
||||
|
||||
async deleteObjects(keys: string[]) {
|
||||
const uniqueKeys = [...new Set(keys.filter(Boolean))];
|
||||
if (!uniqueKeys.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = 1000;
|
||||
for (let index = 0; index < uniqueKeys.length; index += chunkSize) {
|
||||
const chunk = uniqueKeys.slice(index, index + chunkSize);
|
||||
try {
|
||||
await this.internalClient.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: chunk.map((Key) => ({ Key })),
|
||||
Quiet: true
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Не удалось удалить объекты MinIO (${chunk.length} шт.): ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user