Original
This commit is contained in:
113
apps/server/prisma/clean-db.ts
Normal file
113
apps/server/prisma/clean-db.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Полная очистка базы данных от тестовых данных.
|
||||
* Удаляет ВСЕ: пользователей, чаты, сообщения, истории, дружбы.
|
||||
* Таблицы и схема остаются на месте.
|
||||
*
|
||||
* Запуск: npx tsx prisma/clean-db.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function cleanDatabase() {
|
||||
console.log('⚠️ ВНИМАНИЕ: Полная очистка базы данных!\n');
|
||||
|
||||
// Удаляем в правильном порядке (зависимости → родители)
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// 1. Зависимые таблицы
|
||||
const r1 = await prisma.hiddenMessage.deleteMany();
|
||||
counts['HiddenMessage'] = r1.count;
|
||||
|
||||
const r2 = await prisma.readReceipt.deleteMany();
|
||||
counts['ReadReceipt'] = r2.count;
|
||||
|
||||
const r3 = await prisma.reaction.deleteMany();
|
||||
counts['Reaction'] = r3.count;
|
||||
|
||||
const r4 = await prisma.pinnedMessage.deleteMany();
|
||||
counts['PinnedMessage'] = r4.count;
|
||||
|
||||
const r5 = await prisma.media.deleteMany();
|
||||
counts['Media'] = r5.count;
|
||||
|
||||
const r6 = await prisma.storyView.deleteMany();
|
||||
counts['StoryView'] = r6.count;
|
||||
|
||||
const r7 = await prisma.story.deleteMany();
|
||||
counts['Story'] = r7.count;
|
||||
|
||||
// 2. Сообщения
|
||||
const r8 = await prisma.message.deleteMany();
|
||||
counts['Message'] = r8.count;
|
||||
|
||||
// 3. Чаты
|
||||
const r9 = await prisma.chatMember.deleteMany();
|
||||
counts['ChatMember'] = r9.count;
|
||||
|
||||
const r10 = await prisma.chat.deleteMany();
|
||||
counts['Chat'] = r10.count;
|
||||
|
||||
// 4. Дружбы
|
||||
const r11 = await prisma.friendship.deleteMany();
|
||||
counts['Friendship'] = r11.count;
|
||||
|
||||
// 5. Пользователи
|
||||
const r12 = await prisma.user.deleteMany();
|
||||
counts['User'] = r12.count;
|
||||
|
||||
// 6. Чистка папки uploads (кроме avatars/.gitkeep)
|
||||
const uploadsDir = path.join(__dirname, '..', 'uploads');
|
||||
let filesDeleted = 0;
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
const entries = fs.readdirSync(uploadsDir);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(uploadsDir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Для папки avatars — очистить содержимое, но оставить папку
|
||||
if (entry === 'avatars') {
|
||||
const avatarFiles = fs.readdirSync(fullPath);
|
||||
for (const f of avatarFiles) {
|
||||
if (f === '.gitkeep') continue;
|
||||
fs.unlinkSync(path.join(fullPath, f));
|
||||
filesDeleted++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Файлы в корне uploads
|
||||
if (entry !== '.gitkeep') {
|
||||
fs.unlinkSync(fullPath);
|
||||
filesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Вывод результатов
|
||||
console.log('┌──────────────────────────────────────┐');
|
||||
console.log('│ 🧹 База данных очищена! │');
|
||||
console.log('├──────────────────────────────────────┤');
|
||||
for (const [table, count] of Object.entries(counts)) {
|
||||
if (count > 0) {
|
||||
console.log(`│ ${table.padEnd(20)} ${String(count).padStart(6)} удалено │`);
|
||||
}
|
||||
}
|
||||
if (filesDeleted > 0) {
|
||||
console.log(`│ ${'Файлы (uploads)'.padEnd(20)} ${String(filesDeleted).padStart(6)} удалено │`);
|
||||
}
|
||||
console.log('└──────────────────────────────────────┘');
|
||||
console.log('\n✅ Готово. БД чистая, можно начинать с нуля.');
|
||||
}
|
||||
|
||||
cleanDatabase()
|
||||
.catch((e) => {
|
||||
console.error('❌ Ошибка очистки:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
BIN
apps/server/prisma/dev.db
Normal file
BIN
apps/server/prisma/dev.db
Normal file
Binary file not shown.
69
apps/server/prisma/encrypt-existing-files.ts
Normal file
69
apps/server/prisma/encrypt-existing-files.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Encrypt existing unencrypted files in the uploads directory.
|
||||
*
|
||||
* Run once after enabling ENCRYPTION_KEY:
|
||||
* npx ts-node prisma/encrypt-existing-files.ts
|
||||
*
|
||||
* Safe to re-run — skips already-encrypted files (decryption test).
|
||||
*/
|
||||
import '../src/config'; // loads .env & initialises encryption
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { isEncryptionEnabled, encryptFileInPlace, decryptFileToBuffer } from '../src/encrypt';
|
||||
|
||||
const UPLOADS_ROOT = path.join(__dirname, '../uploads');
|
||||
|
||||
function walkDir(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkDir(full));
|
||||
} else {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!isEncryptionEnabled()) {
|
||||
console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🔒 Начало шифрования файлов в uploads/…\n');
|
||||
|
||||
const allFiles = walkDir(UPLOADS_ROOT);
|
||||
console.log(`📁 Найдено ${allFiles.length} файлов`);
|
||||
|
||||
let encrypted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
const relPath = path.relative(UPLOADS_ROOT, filePath);
|
||||
try {
|
||||
// Try to decrypt — if it works, file is already encrypted
|
||||
const decrypted = decryptFileToBuffer(filePath);
|
||||
if (decrypted !== null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// File is not encrypted — encrypt it
|
||||
encryptFileInPlace(filePath);
|
||||
encrypted++;
|
||||
process.stdout.write(` ✔ ${encrypted} зашифровано, ${skipped} пропущено\r`);
|
||||
} catch (e) {
|
||||
console.error(`\n ❌ Ошибка с файлом ${relPath}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n\n✅ Готово! Зашифровано ${encrypted} файлов, пропущено ${skipped} (уже зашифрованы).`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Ошибка:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
67
apps/server/prisma/encrypt-existing.ts
Normal file
67
apps/server/prisma/encrypt-existing.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Migrate existing plain-text messages to encrypted form.
|
||||
*
|
||||
* Run once after enabling ENCRYPTION_KEY:
|
||||
* npx ts-node prisma/encrypt-existing.ts
|
||||
*
|
||||
* Safe to re-run — skips already-encrypted messages ("enc:v1:" prefix).
|
||||
*/
|
||||
import '../src/config'; // loads .env & initialises encryption
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { encryptText, isEncryptionEnabled } from '../src/encrypt';
|
||||
|
||||
// Use raw PrismaClient to bypass the encryption middleware (avoid double-encryption)
|
||||
const rawPrisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
if (!isEncryptionEnabled()) {
|
||||
console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🔒 Начало шифрования существующих сообщений…\n');
|
||||
|
||||
// We bypass the Prisma middleware by using $queryRawUnsafe for the SELECT,
|
||||
// then use raw UPDATE to avoid double-encryption via middleware.
|
||||
const messages: Array<{ id: string; content: string | null; quote: string | null }> =
|
||||
await rawPrisma.$queryRaw`
|
||||
SELECT id, content, quote FROM "Message"
|
||||
WHERE (content IS NOT NULL AND content != '' AND content NOT LIKE 'enc:v1:%')
|
||||
OR (quote IS NOT NULL AND quote != '' AND quote NOT LIKE 'enc:v1:%')
|
||||
`;
|
||||
|
||||
console.log(`📝 Найдено ${messages.length} незашифрованных сообщений`);
|
||||
|
||||
let encrypted = 0;
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
|
||||
const batch = messages.slice(i, i + BATCH_SIZE);
|
||||
await rawPrisma.$transaction(
|
||||
batch.map((msg) => {
|
||||
const newContent = msg.content && !msg.content.startsWith('enc:v1:')
|
||||
? encryptText(msg.content) : null;
|
||||
const newQuote = msg.quote && !msg.quote.startsWith('enc:v1:')
|
||||
? encryptText(msg.quote) : null;
|
||||
|
||||
return rawPrisma.$executeRaw`
|
||||
UPDATE "Message"
|
||||
SET content = COALESCE(${newContent}::text, content),
|
||||
quote = COALESCE(${newQuote}::text, quote)
|
||||
WHERE id = ${msg.id}
|
||||
`;
|
||||
})
|
||||
);
|
||||
encrypted += batch.length;
|
||||
process.stdout.write(` ✔ ${encrypted}/${messages.length}\r`);
|
||||
}
|
||||
|
||||
console.log(`\n\n✅ Готово! Зашифровано ${encrypted} сообщений.`);
|
||||
console.log('⚠ СОХРАНИТЕ КЛЮЧ ENCRYPTION_KEY В НАДЁЖНОМ МЕСТЕ — без него данные не восстановить!');
|
||||
await rawPrisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Ошибка миграции:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
195
apps/server/prisma/schema.prisma
Normal file
195
apps/server/prisma/schema.prisma
Normal file
@@ -0,0 +1,195 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
displayName String @default("")
|
||||
email String? @unique
|
||||
phone String?
|
||||
password String
|
||||
avatar String?
|
||||
bio String?
|
||||
birthday String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeen DateTime @default(now())
|
||||
isOnline Boolean @default(false)
|
||||
hideStoryViews Boolean @default(false)
|
||||
registrationIp String?
|
||||
|
||||
messages Message[]
|
||||
chatMembers ChatMember[]
|
||||
reactions Reaction[]
|
||||
readReceipts ReadReceipt[]
|
||||
forwardedMessages Message[] @relation("ForwardedFrom")
|
||||
stories Story[]
|
||||
storyViews StoryView[]
|
||||
friendshipsSent Friendship[] @relation("FriendshipsSent")
|
||||
friendshipsReceived Friendship[] @relation("FriendshipsReceived")
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id @default(uuid())
|
||||
type String @default("personal")
|
||||
name String?
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
members ChatMember[]
|
||||
messages Message[]
|
||||
pinnedMessages PinnedMessage[]
|
||||
}
|
||||
|
||||
model ChatMember {
|
||||
id String @id @default(uuid())
|
||||
chatId String
|
||||
userId String
|
||||
role String @default("member")
|
||||
joinedAt DateTime @default(now())
|
||||
isMuted Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
clearedAt DateTime?
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([chatId, userId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
chatId String
|
||||
senderId String
|
||||
content String?
|
||||
type String @default("text")
|
||||
replyToId String?
|
||||
quote String?
|
||||
forwardedFromId String?
|
||||
isEdited Boolean @default(false)
|
||||
isDeleted Boolean @default(false)
|
||||
scheduledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
sender User @relation(fields: [senderId], references: [id])
|
||||
replyTo Message? @relation("Reply", fields: [replyToId], references: [id])
|
||||
replies Message[] @relation("Reply")
|
||||
forwardedFrom User? @relation("ForwardedFrom", fields: [forwardedFromId], references: [id])
|
||||
|
||||
media Media[]
|
||||
reactions Reaction[]
|
||||
readBy ReadReceipt[]
|
||||
pinnedIn PinnedMessage[]
|
||||
hiddenBy HiddenMessage[]
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
type String
|
||||
url String
|
||||
filename String?
|
||||
thumbnail String?
|
||||
size Int?
|
||||
duration Float?
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Reaction {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
userId String
|
||||
emoji String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([messageId, userId, emoji])
|
||||
}
|
||||
|
||||
model ReadReceipt {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
userId String
|
||||
readAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([messageId, userId])
|
||||
}
|
||||
|
||||
model PinnedMessage {
|
||||
id String @id @default(uuid())
|
||||
chatId String
|
||||
messageId String
|
||||
pinnedAt DateTime @default(now())
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([chatId, messageId])
|
||||
}
|
||||
|
||||
model Story {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
type String @default("text")
|
||||
mediaUrl String?
|
||||
content String?
|
||||
bgColor String?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
views StoryView[]
|
||||
}
|
||||
|
||||
model StoryView {
|
||||
id String @id @default(uuid())
|
||||
storyId String
|
||||
userId String
|
||||
viewedAt DateTime @default(now())
|
||||
|
||||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([storyId, userId])
|
||||
}
|
||||
|
||||
model HiddenMessage {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
userId String
|
||||
hiddenAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([messageId, userId])
|
||||
}
|
||||
|
||||
model Friendship {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
friendId String
|
||||
status String @default("pending") // pending | accepted | declined
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation("FriendshipsSent", fields: [userId], references: [id], onDelete: Cascade)
|
||||
friend User @relation("FriendshipsReceived", fields: [friendId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, friendId])
|
||||
}
|
||||
48
apps/server/prisma/seed.ts
Normal file
48
apps/server/prisma/seed.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Заполнение базы данных...\n');
|
||||
|
||||
const password = await bcrypt.hash('demo123', 10);
|
||||
|
||||
const usersData = [
|
||||
{ username: 'evgeniy', displayName: 'Евгений', bio: 'Создатель Vortex' },
|
||||
{ username: 'anastasia', displayName: 'Анастасия', bio: 'Дизайнер интерфейсов' },
|
||||
{ username: 'artem', displayName: 'Артём', bio: 'Frontend разработчик' },
|
||||
{ username: 'polina', displayName: 'Полина', bio: 'Backend разработчик' },
|
||||
{ username: 'daniil', displayName: 'Даниил', bio: 'DevOps инженер' },
|
||||
{ username: 'vladimir', displayName: 'Владимир', bio: 'Product Manager' },
|
||||
];
|
||||
|
||||
const users = await Promise.all(
|
||||
usersData.map((u) =>
|
||||
prisma.user.upsert({
|
||||
where: { username: u.username },
|
||||
update: { displayName: u.displayName, bio: u.bio },
|
||||
create: {
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
password,
|
||||
bio: u.bio,
|
||||
isOnline: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`Создано ${users.length} пользователей`);
|
||||
|
||||
console.log('\n--- Тестовые аккаунты ---');
|
||||
console.log('Пароль для всех: demo123\n');
|
||||
for (const user of users) {
|
||||
console.log(` ${user.username} (${user.displayName})`);
|
||||
}
|
||||
console.log('\nЗаполнение завершено!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user