fix sso core folder
This commit is contained in:
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user