english version update
This commit is contained in:
@@ -13,33 +13,35 @@ datasource db {
|
||||
// VPS/Server models removed - moving to S3 storage
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
email String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
email String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
// plans Plan[] @relation("UserPlans")
|
||||
operator Int @default(0)
|
||||
isAdmin Boolean @default(false)
|
||||
tickets Ticket[] @relation("UserTickets")
|
||||
responses Response[] @relation("OperatorResponses")
|
||||
checks Check[] @relation("UserChecks")
|
||||
balance Float @default(0)
|
||||
notifications Notification[]
|
||||
operator Int @default(0)
|
||||
isAdmin Boolean @default(false)
|
||||
tickets Ticket[] @relation("UserTickets")
|
||||
responses Response[] @relation("OperatorResponses")
|
||||
checks Check[] @relation("UserChecks")
|
||||
balance Float @default(0)
|
||||
notifications Notification[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
transactions Transaction[] // История всех транзакций
|
||||
posts Post[] @relation("PostAuthor") // Статьи блога
|
||||
comments Comment[] @relation("UserComments") // Комментарии
|
||||
buckets StorageBucket[] // S3 хранилища пользователя
|
||||
checkoutSessions StorageCheckoutSession[]
|
||||
|
||||
transactions Transaction[] // История всех транзакций
|
||||
posts Post[] @relation("PostAuthor") // Статьи блога
|
||||
comments Comment[] @relation("UserComments") // Комментарии
|
||||
buckets StorageBucket[] // S3 хранилища пользователя
|
||||
checkoutSessions StorageCheckoutSession[]
|
||||
// Список промокодов, использованных пользователем
|
||||
usedPromoCodes PromoCode[]
|
||||
|
||||
// Новые relations для расширенных настроек
|
||||
sessions Session[]
|
||||
loginHistory LoginHistory[]
|
||||
apiKeys APIKey[]
|
||||
notificationSettings NotificationSettings?
|
||||
profile UserProfile?
|
||||
qrLoginRequests QrLoginRequest[]
|
||||
sessions Session[]
|
||||
loginHistory LoginHistory[]
|
||||
apiKeys APIKey[]
|
||||
notificationSettings NotificationSettings?
|
||||
profile UserProfile?
|
||||
qrLoginRequests QrLoginRequest[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@@ -53,48 +55,47 @@ model Check {
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation("UserChecks", fields: [userId], references: [id])
|
||||
|
||||
@@map("check")
|
||||
@@map("check")
|
||||
}
|
||||
|
||||
|
||||
model Service {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
price Float
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
price Float
|
||||
// planId Int?
|
||||
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||
|
||||
@@map("service")
|
||||
@@map("service")
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
message String @db.Text
|
||||
userId Int
|
||||
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
|
||||
priority String @default("normal") // low, normal, high, urgent
|
||||
category String @default("general") // general, technical, billing, other
|
||||
assignedTo Int? // ID оператора, которому назначен тикет
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
closedAt DateTime?
|
||||
responses Response[] @relation("TicketResponses")
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
message String @db.Text
|
||||
userId Int
|
||||
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
|
||||
priority String @default("normal") // low, normal, high, urgent
|
||||
category String @default("general") // general, technical, billing, other
|
||||
assignedTo Int? // ID оператора, которому назначен тикет
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
closedAt DateTime?
|
||||
responses Response[] @relation("TicketResponses")
|
||||
attachments TicketAttachment[]
|
||||
user User? @relation("UserTickets", fields: [userId], references: [id])
|
||||
user User? @relation("UserTickets", fields: [userId], references: [id])
|
||||
|
||||
@@map("ticket")
|
||||
}
|
||||
|
||||
model Response {
|
||||
id Int @id @default(autoincrement())
|
||||
ticketId Int
|
||||
operatorId Int
|
||||
message String @db.Text
|
||||
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
|
||||
id Int @id @default(autoincrement())
|
||||
ticketId Int
|
||||
operatorId Int
|
||||
message String @db.Text
|
||||
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
|
||||
attachments ResponseAttachment[]
|
||||
|
||||
@@map("response")
|
||||
@@ -102,17 +103,17 @@ model Response {
|
||||
|
||||
// Прикреплённые файлы к тикетам
|
||||
model TicketAttachment {
|
||||
id Int @id @default(autoincrement())
|
||||
ticketId Int
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
|
||||
filename String
|
||||
fileUrl String
|
||||
fileSize Int // Размер в байтах
|
||||
mimeType String
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
ticketId Int
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
|
||||
filename String
|
||||
fileUrl String
|
||||
fileSize Int // Размер в байтах
|
||||
mimeType String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@map("ticket_attachment")
|
||||
}
|
||||
|
||||
@@ -121,32 +122,32 @@ model ResponseAttachment {
|
||||
id Int @id @default(autoincrement())
|
||||
responseId Int
|
||||
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||
|
||||
filename String
|
||||
fileUrl String
|
||||
fileSize Int
|
||||
mimeType String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
filename String
|
||||
fileUrl String
|
||||
fileSize Int
|
||||
mimeType String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("response_attachment")
|
||||
}
|
||||
|
||||
// QR-код авторизация (как в Telegram Web)
|
||||
model QrLoginRequest {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128) // Уникальный код QR
|
||||
userId Int? // После подтверждения - ID пользователя
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
status String @default("pending") // pending, confirmed, expired, rejected
|
||||
ipAddress String?
|
||||
userAgent String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime // Через 60 секунд
|
||||
confirmedAt DateTime?
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128) // Уникальный код QR
|
||||
userId Int? // После подтверждения - ID пользователя
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
status String @default("pending") // pending, confirmed, expired, rejected
|
||||
ipAddress String?
|
||||
userAgent String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime // Через 60 секунд
|
||||
confirmedAt DateTime?
|
||||
|
||||
@@index([code])
|
||||
@@index([status, expiresAt])
|
||||
@@map("qr_login_request")
|
||||
@@ -154,34 +155,34 @@ model QrLoginRequest {
|
||||
|
||||
// История всех транзакций (пополнения, списания, возвраты)
|
||||
model Transaction {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
amount Float
|
||||
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
|
||||
description String
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
amount Float
|
||||
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
|
||||
description String
|
||||
balanceBefore Float
|
||||
balanceAfter Float
|
||||
adminId Int? // ID админа, если операция выполнена админом
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
adminId Int? // ID админа, если операция выполнена админом
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("transaction")
|
||||
}
|
||||
|
||||
// Блог
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String @db.Text // Rich text content (HTML)
|
||||
excerpt String? @db.Text // Краткое описание для ленты
|
||||
coverImage String? // URL обложки
|
||||
url String @unique // Пользовательский URL (blog_name)
|
||||
status String @default("draft") // draft, published, archived
|
||||
content String @db.Text // Rich text content (HTML)
|
||||
excerpt String? @db.Text // Краткое описание для ленты
|
||||
coverImage String? // URL обложки
|
||||
url String @unique // Пользовательский URL (blog_name)
|
||||
status String @default("draft") // draft, published, archived
|
||||
authorId Int
|
||||
author User @relation("PostAuthor", fields: [authorId], references: [id])
|
||||
views Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
author User @relation("PostAuthor", fields: [authorId], references: [id])
|
||||
views Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
publishedAt DateTime?
|
||||
comments Comment[]
|
||||
|
||||
@@ -190,41 +191,41 @@ model Post {
|
||||
|
||||
// Комментарии к статьям блога
|
||||
model Comment {
|
||||
id Int @id @default(autoincrement())
|
||||
postId Int
|
||||
userId Int? // null если комментарий от гостя
|
||||
authorName String? // Имя автора (для гостей)
|
||||
content String @db.Text
|
||||
status String @default("pending") // pending, approved, rejected
|
||||
createdAt DateTime @default(now())
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("UserComments", fields: [userId], references: [id])
|
||||
id Int @id @default(autoincrement())
|
||||
postId Int
|
||||
userId Int? // null если комментарий от гостя
|
||||
authorName String? // Имя автора (для гостей)
|
||||
content String @db.Text
|
||||
status String @default("pending") // pending, approved, rejected
|
||||
createdAt DateTime @default(now())
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("UserComments", fields: [userId], references: [id])
|
||||
|
||||
@@map("comment")
|
||||
}
|
||||
|
||||
// Модель для уведомлений
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
|
||||
title String
|
||||
message String @db.Text
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
|
||||
title String
|
||||
message String @db.Text
|
||||
|
||||
// Связанные сущности (опционально)
|
||||
ticketId Int?
|
||||
checkId Int?
|
||||
|
||||
ticketId Int?
|
||||
checkId Int?
|
||||
|
||||
// Метаданные
|
||||
actionUrl String? // URL для перехода при клике
|
||||
icon String? // Иконка (emoji или path)
|
||||
color String? // Цвет (green, blue, orange, red, purple)
|
||||
|
||||
actionUrl String? // URL для перехода при клике
|
||||
icon String? // Иконка (emoji или path)
|
||||
color String? // Цвет (green, blue, orange, red, purple)
|
||||
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@index([userId, isRead])
|
||||
@@index([userId, createdAt])
|
||||
@@map("notification")
|
||||
@@ -232,19 +233,19 @@ model Notification {
|
||||
|
||||
// Модель для Push-подписок (Web Push API)
|
||||
model PushSubscription {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
endpoint String @db.VarChar(512)
|
||||
p256dh String @db.Text // Публичный ключ для шифрования
|
||||
auth String @db.Text // Токен аутентификации
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
endpoint String @db.VarChar(512)
|
||||
p256dh String @db.Text // Публичный ключ для шифрования
|
||||
auth String @db.Text // Токен аутентификации
|
||||
|
||||
userAgent String? @db.Text // Браузер/устройство
|
||||
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastUsed DateTime @default(now())
|
||||
|
||||
|
||||
@@unique([userId, endpoint])
|
||||
@@index([userId])
|
||||
@@map("push_subscription")
|
||||
@@ -252,40 +253,40 @@ model PushSubscription {
|
||||
|
||||
// Активные сеансы пользователя
|
||||
model Session {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
token String @unique @db.VarChar(500) // JWT refresh token
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
token String @unique @db.VarChar(500) // JWT refresh token
|
||||
ipAddress String?
|
||||
userAgent String? @db.Text
|
||||
device String? // Desktop, Mobile, Tablet
|
||||
browser String? // Chrome, Firefox, Safari, etc.
|
||||
location String? // Город/страна по IP
|
||||
|
||||
userAgent String? @db.Text
|
||||
device String? // Desktop, Mobile, Tablet
|
||||
browser String? // Chrome, Firefox, Safari, etc.
|
||||
location String? // Город/страна по IP
|
||||
|
||||
lastActivity DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
|
||||
@@index([userId])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
// История входов
|
||||
model LoginHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
ipAddress String
|
||||
userAgent String? @db.Text
|
||||
userAgent String? @db.Text
|
||||
device String?
|
||||
browser String?
|
||||
location String?
|
||||
|
||||
|
||||
success Boolean @default(true) // true = успешный вход, false = неудачная попытка
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@map("login_history")
|
||||
@@ -293,20 +294,20 @@ model LoginHistory {
|
||||
|
||||
// API ключи для разработчиков
|
||||
model APIKey {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String // Название (например, "Production API")
|
||||
key String @unique @db.VarChar(64) // Сам API ключ
|
||||
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
|
||||
|
||||
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
|
||||
|
||||
lastUsed DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String // Название (например, "Production API")
|
||||
key String @unique @db.VarChar(64) // Сам API ключ
|
||||
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
|
||||
|
||||
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
|
||||
|
||||
lastUsed DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
@@index([userId])
|
||||
@@index([key])
|
||||
@@map("api_key")
|
||||
@@ -317,95 +318,95 @@ model NotificationSettings {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
// Email уведомления
|
||||
emailBalanceLow Boolean @default(true)
|
||||
emailPaymentCharged Boolean @default(true)
|
||||
emailTicketReply Boolean @default(true)
|
||||
emailNewsletter Boolean @default(false)
|
||||
|
||||
emailBalanceLow Boolean @default(true)
|
||||
emailPaymentCharged Boolean @default(true)
|
||||
emailTicketReply Boolean @default(true)
|
||||
emailNewsletter Boolean @default(false)
|
||||
|
||||
// Push уведомления
|
||||
pushBalanceLow Boolean @default(true)
|
||||
pushPaymentCharged Boolean @default(true)
|
||||
pushTicketReply Boolean @default(true)
|
||||
|
||||
pushBalanceLow Boolean @default(true)
|
||||
pushPaymentCharged Boolean @default(true)
|
||||
pushTicketReply Boolean @default(true)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@map("notification_settings")
|
||||
}
|
||||
|
||||
// Настройки профиля
|
||||
model UserProfile {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
avatarUrl String? // Путь к аватару
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
avatarUrl String? // Путь к аватару
|
||||
phoneNumber String?
|
||||
timezone String? @default("Europe/Moscow")
|
||||
language String? @default("ru")
|
||||
|
||||
timezone String? @default("Europe/Moscow")
|
||||
language String? @default("ru")
|
||||
|
||||
// Настройки приватности
|
||||
profilePublic Boolean @default(false)
|
||||
showEmail Boolean @default(false)
|
||||
|
||||
|
||||
// 2FA
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String? @db.Text
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("user_profile")
|
||||
}
|
||||
|
||||
// S3 Bucket модель
|
||||
model StorageBucket {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String // Уникальное имя бакета в рамках пользователя
|
||||
plan String // Код тарифа из StoragePlan
|
||||
quotaGb Int // Лимит включённого объёма в GB
|
||||
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
|
||||
objectCount Int @default(0)
|
||||
storageClass String @default("standard") // standard, infrequent, archive
|
||||
region String @default("ru-central-1")
|
||||
public Boolean @default(false)
|
||||
versioning Boolean @default(false)
|
||||
status String @default("active") // active, grace, suspended
|
||||
monthlyPrice Float
|
||||
nextBillingDate DateTime?
|
||||
lastBilledAt DateTime?
|
||||
autoRenew Boolean @default(true)
|
||||
usageSyncedAt DateTime?
|
||||
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
|
||||
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
|
||||
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
|
||||
consoleCredential StorageConsoleCredential?
|
||||
name String // Уникальное имя бакета в рамках пользователя
|
||||
plan String // Код тарифа из StoragePlan
|
||||
quotaGb Int // Лимит включённого объёма в GB
|
||||
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
|
||||
objectCount Int @default(0)
|
||||
storageClass String @default("standard") // standard, infrequent, archive
|
||||
region String @default("ru-central-1")
|
||||
public Boolean @default(false)
|
||||
versioning Boolean @default(false)
|
||||
status String @default("active") // active, grace, suspended
|
||||
monthlyPrice Float
|
||||
nextBillingDate DateTime?
|
||||
lastBilledAt DateTime?
|
||||
autoRenew Boolean @default(true)
|
||||
usageSyncedAt DateTime?
|
||||
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
|
||||
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
|
||||
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
|
||||
consoleCredential StorageConsoleCredential?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accessKeys StorageAccessKey[]
|
||||
accessKeys StorageAccessKey[]
|
||||
|
||||
@@unique([userId, name]) // Имя уникально в рамках пользователя
|
||||
@@index([userId])
|
||||
@@index([region])
|
||||
@@index([storageClass])
|
||||
@@unique([userId, name]) // Имя уникально в рамках пользователя
|
||||
@@map("storage_bucket")
|
||||
}
|
||||
|
||||
model StorageAccessKey {
|
||||
id Int @id @default(autoincrement())
|
||||
bucketId Int
|
||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
bucketId Int
|
||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||
|
||||
accessKey String @unique
|
||||
secretKey String // хранится в зашифрованном виде
|
||||
accessKey String @unique
|
||||
secretKey String // хранится в зашифрованном виде
|
||||
label String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
@@index([bucketId])
|
||||
@@ -413,36 +414,36 @@ model StorageAccessKey {
|
||||
}
|
||||
|
||||
model StorageConsoleCredential {
|
||||
id Int @id @default(autoincrement())
|
||||
bucketId Int @unique
|
||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
bucketId Int @unique
|
||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||
|
||||
login String
|
||||
passwordHash String
|
||||
login String
|
||||
passwordHash String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю)
|
||||
|
||||
@@map("storage_console_credential")
|
||||
}
|
||||
|
||||
model StoragePlan {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
name String
|
||||
price Float
|
||||
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
|
||||
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
|
||||
requestsPerGb Int? // Количество операций на 1 GB хранения
|
||||
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
|
||||
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
|
||||
requestLimit String // Текстовое описание лимита операций
|
||||
order Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
name String
|
||||
price Float
|
||||
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
|
||||
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
|
||||
requestsPerGb Int? // Количество операций на 1 GB хранения
|
||||
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
|
||||
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
|
||||
requestLimit String // Текстовое описание лимита операций
|
||||
order Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
buckets StorageBucket[]
|
||||
checkoutSessions StorageCheckoutSession[]
|
||||
@@ -451,22 +452,22 @@ model StoragePlan {
|
||||
}
|
||||
|
||||
model StorageCheckoutSession {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
planId Int
|
||||
plan StoragePlan @relation(fields: [planId], references: [id])
|
||||
planCode String
|
||||
planName String
|
||||
planDescription String?
|
||||
price Float
|
||||
promoCodeId Int?
|
||||
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
|
||||
promoDiscount Float? @default(0)
|
||||
promoCodeId Int?
|
||||
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
|
||||
promoDiscount Float? @default(0)
|
||||
quotaGb Int
|
||||
bandwidthGb Int
|
||||
requestLimit String
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
|
||||
@@ -486,40 +487,43 @@ model StorageRegion {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
buckets StorageBucket[] @relation("BucketRegion")
|
||||
buckets StorageBucket[] @relation("BucketRegion")
|
||||
|
||||
@@map("storage_region")
|
||||
}
|
||||
|
||||
model StorageClass {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
redundancy String?
|
||||
performance String?
|
||||
retrievalFee String?
|
||||
isDefault Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
redundancy String?
|
||||
performance String?
|
||||
retrievalFee String?
|
||||
isDefault Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
buckets StorageBucket[] @relation("BucketClass")
|
||||
buckets StorageBucket[] @relation("BucketClass")
|
||||
|
||||
@@map("storage_class")
|
||||
}
|
||||
|
||||
model PromoCode {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
amount Float // discount amount in RUB
|
||||
used Boolean @default(false)
|
||||
usedBy Int?
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
amount Float // discount amount in RUB
|
||||
used Boolean @default(false)
|
||||
usedBy Int?
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [usedBy], references: [id])
|
||||
user User? @relation(fields: [usedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
// Обратная связь для корзин, в которые применяли этот промокод
|
||||
checkoutSessions StorageCheckoutSession[]
|
||||
|
||||
@@map("promo_code")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,15 +111,72 @@ app.get('/', async (req, res) => {
|
||||
// ==================== SITEMAP ====================
|
||||
app.get('/sitemap.xml', (req, res) => {
|
||||
const baseUrl = 'https://ospab.host';
|
||||
|
||||
const staticPages = [
|
||||
{ loc: '/', priority: '1.0', changefreq: 'weekly', description: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов' },
|
||||
{ loc: '/about', priority: '0.9', changefreq: 'monthly', description: 'О компании - Современная платформа хранения данных' },
|
||||
{ loc: '/login', priority: '0.7', changefreq: 'monthly', description: 'Вход в панель управления с QR-аутентификацией' },
|
||||
{ loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' },
|
||||
{ loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' },
|
||||
{ loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' },
|
||||
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' },
|
||||
|
||||
const pages = [
|
||||
// Главная страница
|
||||
{
|
||||
loc: '/',
|
||||
priority: '1.0',
|
||||
changefreq: 'weekly',
|
||||
ru: { title: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов', description: 'Надёжное облачное S3-совместимое хранилище в Великом Новгороде' },
|
||||
en: { title: 'Ospab Host - Cloud S3 Storage and Website Hosting', description: 'Reliable S3-compatible cloud storage in Veliky Novgorod' }
|
||||
},
|
||||
// О компании
|
||||
{
|
||||
loc: '/about',
|
||||
priority: '0.9',
|
||||
changefreq: 'monthly',
|
||||
ru: { title: 'О компании - Современная платформа хранения данных', description: 'Узнайте о ospab.host - платформе облачного хранилища в Великом Новгороде' },
|
||||
en: { title: 'About Us - Modern Data Storage Platform', description: 'Learn about ospab.host - cloud storage platform in Veliky Novgorod' }
|
||||
},
|
||||
// Вход
|
||||
{
|
||||
loc: '/login',
|
||||
priority: '0.7',
|
||||
changefreq: 'monthly',
|
||||
ru: { title: 'Вход в панель управления с QR-аутентификацией', description: 'Войдите в ваш личный кабинет ospab.host' },
|
||||
en: { title: 'Login to Control Panel with QR Authentication', description: 'Sign in to your ospab.host account' }
|
||||
},
|
||||
// Регистрация
|
||||
{
|
||||
loc: '/register',
|
||||
priority: '0.8',
|
||||
changefreq: 'monthly',
|
||||
ru: { title: 'Регистрация аккаунта - Начните за 2 минуты', description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем' },
|
||||
en: { title: 'Account Registration - Start in 2 Minutes', description: 'Register with ospab.host and start using cloud storage' }
|
||||
},
|
||||
// Блог
|
||||
{
|
||||
loc: '/blog',
|
||||
priority: '0.85',
|
||||
changefreq: 'daily',
|
||||
ru: { title: 'Блог о S3 хранилище и хостинге', description: 'Статьи о хостинге, S3 хранилище и облачных технологиях' },
|
||||
en: { title: 'Blog about S3 Storage and Hosting', description: 'Articles about hosting, S3 storage and cloud technologies' }
|
||||
},
|
||||
// Тарифы
|
||||
{
|
||||
loc: '/tariffs',
|
||||
priority: '0.9',
|
||||
changefreq: 'weekly',
|
||||
ru: { title: 'Тарифы на облачное S3 хранилище', description: 'Выберите подходящий тариф для вашего проекта' },
|
||||
en: { title: 'Cloud S3 Storage Plans', description: 'Choose the right plan for your project' }
|
||||
},
|
||||
// Условия использования
|
||||
{
|
||||
loc: '/terms',
|
||||
priority: '0.5',
|
||||
changefreq: 'yearly',
|
||||
ru: { title: 'Условия использования сервиса', description: 'Правила и условия использования ospab.host' },
|
||||
en: { title: 'Terms of Service', description: 'Rules and conditions for using ospab.host' }
|
||||
},
|
||||
// Политика конфиденциальности
|
||||
{
|
||||
loc: '/privacy',
|
||||
priority: '0.5',
|
||||
changefreq: 'yearly',
|
||||
ru: { title: 'Политика конфиденциальности и защита данных', description: 'Как мы защищаем ваши данные и обеспечиваем конфиденциальность' },
|
||||
en: { title: 'Privacy Policy and Data Protection', description: 'How we protect your data and ensure privacy' }
|
||||
}
|
||||
];
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
@@ -127,12 +184,25 @@ app.get('/sitemap.xml', (req, res) => {
|
||||
|
||||
const lastmod = new Date().toISOString().split('T')[0];
|
||||
|
||||
for (const page of staticPages) {
|
||||
for (const page of pages) {
|
||||
// Русская версия (без префикса)
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
|
||||
xml += ` <lastmod>${lastmod}</lastmod>\n`;
|
||||
xml += ` <priority>${page.priority}</priority>\n`;
|
||||
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
|
||||
xml += ' <xhtml:link rel="alternate" hreflang="ru" href="' + baseUrl + page.loc + '"/>\n';
|
||||
xml += ' <xhtml:link rel="alternate" hreflang="en" href="' + baseUrl + '/en' + page.loc + '"/>\n';
|
||||
xml += ' </url>\n';
|
||||
|
||||
// Английская версия (с префиксом /en)
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${baseUrl}/en${page.loc}</loc>\n`;
|
||||
xml += ` <lastmod>${lastmod}</lastmod>\n`;
|
||||
xml += ` <priority>${page.priority}</priority>\n`;
|
||||
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
|
||||
xml += ' <xhtml:link rel="alternate" hreflang="ru" href="' + baseUrl + page.loc + '"/>\n';
|
||||
xml += ' <xhtml:link rel="alternate" hreflang="en" href="' + baseUrl + '/en' + page.loc + '"/>\n';
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
|
||||
@@ -145,20 +215,29 @@ app.get('/sitemap.xml', (req, res) => {
|
||||
// ==================== ROBOTS.TXT ====================
|
||||
app.get('/robots.txt', (req, res) => {
|
||||
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
|
||||
# Хранение данных, техподдержка 24/7
|
||||
# Cloud S3 Storage and Website Hosting
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /about
|
||||
Allow: /en/about
|
||||
Allow: /login
|
||||
Allow: /en/login
|
||||
Allow: /register
|
||||
Allow: /en/register
|
||||
Allow: /blog
|
||||
Allow: /en/blog
|
||||
Allow: /blog/*
|
||||
Allow: /en/blog/*
|
||||
Allow: /tariffs
|
||||
Allow: /en/tariffs
|
||||
Allow: /terms
|
||||
Allow: /en/terms
|
||||
Allow: /privacy
|
||||
Allow: /en/privacy
|
||||
Allow: /uploads/blog
|
||||
|
||||
# Запрет индексации приватных разделов
|
||||
# Disallow private sections / Запрет индексации приватных разделов
|
||||
Disallow: /dashboard
|
||||
Disallow: /dashboard/*
|
||||
Disallow: /api/
|
||||
@@ -171,7 +250,7 @@ Disallow: /uploads/checks
|
||||
|
||||
Sitemap: https://ospab.host/sitemap.xml
|
||||
|
||||
# Поисковые роботы
|
||||
# Search engine robots / Поисковые роботы
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
Crawl-delay: 0
|
||||
|
||||
@@ -10,12 +10,18 @@ import {
|
||||
} from './account.service';
|
||||
import { prisma } from '../../prisma/client';
|
||||
|
||||
// Хелпер для извлечения сообщения из ошибки
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return getErrorMessage(error);
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить информацию о текущем пользователе
|
||||
*/
|
||||
export const getAccountInfo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -33,7 +39,7 @@ export const getAccountInfo = async (req: Request, res: Response) => {
|
||||
*/
|
||||
export const requestPasswordChangeHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -71,7 +77,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка запроса смены пароля:', error);
|
||||
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
||||
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +86,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
|
||||
*/
|
||||
export const confirmPasswordChangeHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -99,7 +105,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка подтверждения смены пароля:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,7 +114,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
|
||||
*/
|
||||
export const requestUsernameChangeHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -139,7 +145,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка запроса смены имени:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка сервера' });
|
||||
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,7 +154,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
|
||||
*/
|
||||
export const confirmUsernameChangeHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -167,7 +173,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка подтверждения смены имени:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,7 +182,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
|
||||
*/
|
||||
export const requestAccountDeletionHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -189,7 +195,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка запроса удаления аккаунта:', error);
|
||||
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
||||
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,7 +204,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
|
||||
*/
|
||||
export const confirmAccountDeletionHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req.user as any)?.id;
|
||||
const userId = (req.user as { id?: number })?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
@@ -217,7 +223,8 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка подтверждения удаления аккаунта:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { createNotification } from '../notification/notification.controller';
|
||||
import { sendNotificationEmail } from '../notification/email.service';
|
||||
|
||||
function toNumeric(value: unknown): number {
|
||||
if (typeof value === 'bigint') {
|
||||
@@ -514,23 +515,50 @@ export class AdminController {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return res.status(400).json({ error: 'У пользователя не указан email' });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
|
||||
console.log(logMsg);
|
||||
|
||||
// Здесь должна быть реальная отправка email (имитация)
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
// Отправляем реальное email уведомление
|
||||
const emailResult = await sendNotificationEmail({
|
||||
to: user.email,
|
||||
username: user.username,
|
||||
title: 'Тестовое уведомление',
|
||||
message: 'Это тестовое email-уведомление от ospab.host. Если вы получили это письмо, email-уведомления настроены корректно.',
|
||||
actionUrl: '/dashboard/notifications',
|
||||
type: 'test_email'
|
||||
});
|
||||
|
||||
if (emailResult.status === 'error') {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: `Ошибка отправки email: ${emailResult.message}`,
|
||||
details: { userId: user.id, email: user.email, time: now }
|
||||
});
|
||||
}
|
||||
|
||||
if (emailResult.status === 'skipped') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'SMTP не настроен. Укажите SMTP_USER и SMTP_PASS в переменных окружения.',
|
||||
details: { userId: user.id, email: user.email, time: now }
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Email-уведомление успешно отправлено (тест)',
|
||||
message: 'Email-уведомление успешно отправлено',
|
||||
details: {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
type: 'email',
|
||||
time: now,
|
||||
status: 'sent (mock)'
|
||||
messageId: 'messageId' in emailResult ? emailResult.messageId : undefined
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import crypto from 'crypto';
|
||||
import { createSession } from '../session/session.controller';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд
|
||||
const QR_EXPIRATION_SECONDS = 180; // QR-код живёт 180 секунд (3 минуты)
|
||||
|
||||
// Генерировать уникальный код для QR
|
||||
function generateQRCode(): string {
|
||||
@@ -14,7 +14,7 @@ function generateQRCode(): string {
|
||||
// Создать новый QR-запрос для логина
|
||||
export async function createQRLoginRequest(req: Request, res: Response) {
|
||||
try {
|
||||
const code = generateQRCode();
|
||||
const code = generateQRCode();
|
||||
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
@@ -31,6 +31,16 @@ export async function createQRLoginRequest(req: Request, res: Response) {
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure QR creation is visible in production logs: write directly to stdout
|
||||
console.log('[QR Create] Создан QR-запрос', JSON.stringify({
|
||||
code: qrRequest.code,
|
||||
ipAddress: qrRequest.ipAddress,
|
||||
userAgent: qrRequest.userAgent?.toString?.().slice(0, 200),
|
||||
host: req.headers.host,
|
||||
origin: req.headers.origin,
|
||||
referer: req.headers.referer
|
||||
}));
|
||||
|
||||
res.json({
|
||||
code: qrRequest.code,
|
||||
expiresAt: qrRequest.expiresAt,
|
||||
@@ -47,21 +57,38 @@ export async function checkQRStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
|
||||
// Log incoming status checks for tracing
|
||||
logger.debug('[QR Status] Проверка статуса QR', {
|
||||
code,
|
||||
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
||||
ua: (req.headers['user-agent'] || '').toString().slice(0, 200)
|
||||
});
|
||||
|
||||
const qrRequest = await prisma.qrLoginRequest.findUnique({
|
||||
where: { code }
|
||||
});
|
||||
|
||||
if (!qrRequest) {
|
||||
// Log as error so it appears in production logs — include host/origin/referer and remote IP for tracing
|
||||
logger.error('[QR Status] QR-код не найден', {
|
||||
code,
|
||||
host: req.headers.host,
|
||||
origin: req.headers.origin,
|
||||
referer: req.headers.referer,
|
||||
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress
|
||||
});
|
||||
return res.status(404).json({ error: 'QR-код не найден' });
|
||||
}
|
||||
|
||||
// Проверяем истёк ли QR-код
|
||||
if (new Date() > qrRequest.expiresAt) {
|
||||
const now = new Date();
|
||||
const expiresIn = Math.max(0, Math.ceil((qrRequest.expiresAt.getTime() - now.getTime()) / 1000));
|
||||
if (expiresIn <= 0) {
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: { status: 'expired' }
|
||||
});
|
||||
return res.json({ status: 'expired' });
|
||||
return res.json({ status: 'expired', expiresAt: qrRequest.expiresAt, expiresIn: 0 });
|
||||
}
|
||||
|
||||
// Если подтверждён, создаём сессию и возвращаем токен
|
||||
@@ -85,8 +112,12 @@ export async function checkQRStatus(req: Request, res: Response) {
|
||||
// Создаём сессию для нового устройства
|
||||
const { token } = await createSession(user.id, req);
|
||||
|
||||
// Удаляем использованный QR-запрос
|
||||
await prisma.qrLoginRequest.delete({ where: { code } });
|
||||
// Попытка безопасно удалить использованный QR-запрос (deleteMany не бросает если записи не найдено)
|
||||
try {
|
||||
await prisma.qrLoginRequest.deleteMany({ where: { code } });
|
||||
} catch (err) {
|
||||
logger.warn('[QR Status] Не удалось удалить QR-запрос (возможно уже удалён)', { code, error: err });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
status: 'confirmed',
|
||||
@@ -102,7 +133,7 @@ export async function checkQRStatus(req: Request, res: Response) {
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ status: qrRequest.status });
|
||||
return res.json({ status: qrRequest.status, expiresAt: qrRequest.expiresAt, expiresIn, ipAddress: qrRequest.ipAddress ?? undefined, userAgent: qrRequest.userAgent ? (qrRequest.userAgent as string).slice(0, 200) : undefined });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка проверки статуса QR:', error);
|
||||
res.status(500).json({ error: 'Ошибка проверки статуса' });
|
||||
@@ -266,3 +297,61 @@ export async function cleanupExpiredQRRequests() {
|
||||
logger.error('[QR Cleanup] Ошибка:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// DEV-only: получить последние N QR-запросов (для отладки)
|
||||
export async function listRecentQRRequests(req: Request, res: Response) {
|
||||
try {
|
||||
// In production allow only requests from localhost (for safe debugging)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const remote = req.socket.remoteAddress || '';
|
||||
const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1');
|
||||
if (!isLocal) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
}
|
||||
|
||||
const limit = Math.min(100, Number(req.query.limit) || 50);
|
||||
const rows = await prisma.qrLoginRequest.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
code: true,
|
||||
status: true,
|
||||
ipAddress: true,
|
||||
userAgent: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ count: rows.length, rows });
|
||||
} catch (error) {
|
||||
logger.error('[QR Debug] Ошибка получения списка QR-запросов:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения списка' });
|
||||
}
|
||||
}
|
||||
|
||||
// DEV-only: получить QR-запрос по коду
|
||||
export async function getQRRequestByCode(req: Request, res: Response) {
|
||||
try {
|
||||
// In production allow only requests from localhost (for safe debugging)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const remote = req.socket.remoteAddress || '';
|
||||
const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1');
|
||||
if (!isLocal) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
}
|
||||
|
||||
const { code } = req.params;
|
||||
const row = await prisma.qrLoginRequest.findUnique({ where: { code } });
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'QR-код не найден' });
|
||||
}
|
||||
res.json(row);
|
||||
} catch (error) {
|
||||
logger.error('[QR Debug] Ошибка получения QR по коду:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
checkQRStatus,
|
||||
confirmQRLogin,
|
||||
rejectQRLogin,
|
||||
markQRAsScanning
|
||||
markQRAsScanning,
|
||||
listRecentQRRequests,
|
||||
getQRRequestByCode
|
||||
} from './qr-auth.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
@@ -16,6 +18,10 @@ router.post('/generate', createQRLoginRequest);
|
||||
// Проверить статус QR-кода (polling, публичный endpoint)
|
||||
router.get('/status/:code', checkQRStatus);
|
||||
|
||||
// DEV-only debug endpoints (возвращают информацию о последних QR-запросах)
|
||||
router.get('/debug/list', listRecentQRRequests);
|
||||
router.get('/debug/get/:code', getQRRequestByCode);
|
||||
|
||||
// Отметить что пользователь открыл страницу подтверждения (требует авторизации)
|
||||
router.post('/scanning', authMiddleware, markQRAsScanning);
|
||||
|
||||
|
||||
@@ -160,7 +160,8 @@ router.post('/cart/:id/apply-promo', authMiddleware, async (req, res) => {
|
||||
return res.json({ success: true, cart: result });
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Не удалось применить промокод';
|
||||
return res.status(400).json({ error: message });
|
||||
const status = typeof message === 'string' && message.includes('PromoCode модель недоступна') ? 500 : 400;
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -661,9 +661,13 @@ function buildPlanFromSession(session: CheckoutSessionRecord, plan?: StoragePlan
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Для custom тарифа всегда берём значения из сессии
|
||||
return {
|
||||
...base,
|
||||
price: toPlainNumber(session.price),
|
||||
quotaGb: session.quotaGb,
|
||||
bandwidthGb: session.bandwidthGb,
|
||||
requestLimit: session.requestLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -672,6 +676,8 @@ type CheckoutSessionPayload = {
|
||||
plan: ReturnType<typeof serializePlan>;
|
||||
price: number;
|
||||
expiresAt: string;
|
||||
originalPrice?: number | null;
|
||||
promoDiscount?: number | null;
|
||||
};
|
||||
|
||||
type CheckoutSessionResult = {
|
||||
@@ -694,11 +700,14 @@ function ensureSessionActive(session: CheckoutSessionRecord, userId: number): Ch
|
||||
}
|
||||
|
||||
function toCheckoutPayload(session: CheckoutSessionRecord, plan?: StoragePlanRecord | null): CheckoutSessionPayload {
|
||||
const original = plan ? Number(plan.price) : toPlainNumber(session.price);
|
||||
return {
|
||||
cartId: session.id,
|
||||
plan: buildPlanFromSession(session, plan),
|
||||
price: toPlainNumber(session.price),
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
originalPrice: original,
|
||||
promoDiscount: session.promoDiscount ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user