v2.0.0: Multi-user authentication with JWT
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 16s

Features:
- User registration and login with JWT tokens
- All data is now user-specific (multi-tenancy)
- Profile page with integrations and logout
- Automatic migration of existing data to first user

Backend changes:
- Added users and refresh_tokens tables
- Added user_id to all data tables (projects, entries, nodes, dictionaries, words, progress, configs, telegram_integrations, weekly_goals)
- JWT authentication middleware
- claimOrphanedData() for data migration

Frontend changes:
- AuthContext for state management
- Login/Register forms
- Profile page (replaced Integrations)
- All API calls use authFetch with Bearer token

Migration notes:
- On first deploy, backend automatically adds user_id columns
- First user to login claims all existing data
This commit is contained in:
poignatov
2026-01-01 18:21:18 +03:00
parent 6015b62d29
commit 4a06ceb7f6
23 changed files with 1970 additions and 279 deletions

View File

@@ -1,2 +1 @@
1.1.1
2.0.0

View File

@@ -47,6 +47,15 @@ WEBHOOK_BASE_URL=https://your-domain.com
# Оставьте пустым, если не хотите использовать проверку секрета
TODOIST_WEBHOOK_SECRET=
# ============================================
# Authentication Configuration
# ============================================
# Секретный ключ для подписи JWT токенов
# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production!
# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production)
# Можно сгенерировать с помощью: openssl rand -base64 32
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# ============================================
# Scheduler Configuration
# ============================================

View File

@@ -1,11 +1,13 @@
module play-eng-backend
go 1.21
go 1.24.0
require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.46.0
)

View File

@@ -1,5 +1,7 @@
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -8,3 +10,5 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
-- Migration: Add users table and user_id to all tables for multi-tenancy
-- This script adds user authentication and makes all data user-specific
-- All statements use IF NOT EXISTS / IF EXISTS for idempotency
-- ============================================
-- Table: users
-- ============================================
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- ============================================
-- Table: refresh_tokens
-- ============================================
CREATE TABLE IF NOT EXISTS refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
-- ============================================
-- Add user_id to projects
-- ============================================
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
-- Drop old unique constraint (name now unique per user, handled in app)
ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name;
-- ============================================
-- Add user_id to entries
-- ============================================
ALTER TABLE entries
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id);
-- ============================================
-- Add user_id to dictionaries
-- ============================================
ALTER TABLE dictionaries
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_dictionaries_user_id ON dictionaries(user_id);
-- ============================================
-- Add user_id to words
-- ============================================
ALTER TABLE words
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_words_user_id ON words(user_id);
-- ============================================
-- Add user_id to progress
-- ============================================
ALTER TABLE progress
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_progress_user_id ON progress(user_id);
-- Drop old unique constraint (word_id now unique per user)
ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key;
-- Create new unique constraint per user
CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id);
-- ============================================
-- Add user_id to configs
-- ============================================
ALTER TABLE configs
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id);
-- ============================================
-- Add user_id to telegram_integrations
-- ============================================
ALTER TABLE telegram_integrations
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_telegram_integrations_user_id ON telegram_integrations(user_id);
-- ============================================
-- Add user_id to weekly_goals
-- ============================================
ALTER TABLE weekly_goals
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_weekly_goals_user_id ON weekly_goals(user_id);
-- ============================================
-- Add user_id to nodes (score data)
-- ============================================
ALTER TABLE nodes
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_nodes_user_id ON nodes(user_id);
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON TABLE users IS 'Users table for authentication and multi-tenancy';
COMMENT ON COLUMN users.email IS 'User email address (unique, used for login)';
COMMENT ON COLUMN users.password_hash IS 'Bcrypt hashed password';
COMMENT ON COLUMN users.name IS 'User display name';
COMMENT ON COLUMN users.is_active IS 'Whether the user account is active';
COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for persistent login';
-- Note: The first user who logs in will automatically become the owner of all
-- existing data (projects, entries, dictionaries, words, etc.) that have NULL user_id.
-- This is handled in the application code (claimOrphanedData function).

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "1.1.1",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -7,13 +7,30 @@ import AddWords from './components/AddWords'
import TestConfigSelection from './components/TestConfigSelection'
import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords'
import Integrations from './components/Integrations'
import Profile from './components/Profile'
import { AuthProvider, useAuth } from './components/auth/AuthContext'
import AuthScreen from './components/auth/AuthScreen'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const CURRENT_WEEK_API_URL = '/playlife-feed'
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
function App() {
function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="text-white text-xl">Загрузка...</div>
</div>
)
}
// Show auth screen if not authenticated
if (!isAuthenticated) {
return <AuthScreen />
}
const [activeTab, setActiveTab] = useState('current')
const [selectedProject, setSelectedProject] = useState(null)
const [loadedTabs, setLoadedTabs] = useState({
@@ -25,7 +42,7 @@ function App() {
'test-config': false,
'add-config': false,
test: false,
integrations: false,
profile: false,
})
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -38,7 +55,7 @@ function App() {
'test-config': false,
'add-config': false,
test: false,
integrations: false,
profile: false,
})
// Параметры для навигации между вкладками
@@ -77,7 +94,7 @@ function App() {
try {
const savedTab = window.localStorage?.getItem('activeTab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations']
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'profile']
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
@@ -104,7 +121,7 @@ function App() {
}
setCurrentWeekError(null)
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
const response = await fetch(CURRENT_WEEK_API_URL)
const response = await authFetch(CURRENT_WEEK_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
@@ -149,7 +166,7 @@ function App() {
setCurrentWeekLoading(false)
}
}
}, [])
}, [authFetch])
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
try {
@@ -159,7 +176,7 @@ function App() {
setFullStatisticsLoading(true)
}
setFullStatisticsError(null)
const response = await fetch(FULL_STATISTICS_API_URL)
const response = await authFetch(FULL_STATISTICS_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
@@ -175,7 +192,7 @@ function App() {
setFullStatisticsLoading(false)
}
}
}, [])
}, [authFetch])
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
const tabsInitializedRef = useRef({
@@ -187,7 +204,7 @@ function App() {
'test-config': false,
'add-config': false,
test: false,
integrations: false,
profile: false,
})
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
@@ -476,9 +493,9 @@ function App() {
</div>
)}
{loadedTabs.integrations && (
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
<Integrations onNavigate={handleNavigate} />
{loadedTabs.profile && (
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
<Profile onNavigate={handleNavigate} />
</div>
)}
</div>
@@ -530,22 +547,21 @@ function App() {
)}
</button>
<button
onClick={() => handleTabChange('integrations')}
onClick={() => handleTabChange('profile')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'integrations'
activeTab === 'profile'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Интеграции"
title="Профиль"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</span>
{activeTab === 'integrations' && (
{activeTab === 'profile' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
@@ -556,6 +572,14 @@ function App() {
)
}
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
)
}
export default App

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './AddConfig.css'
const API_URL = '/api'
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [tryMessage, setTryMessage] = useState('')
const [wordsCount, setWordsCount] = useState('10')
@@ -19,7 +21,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
const loadDictionaries = async () => {
setLoadingDictionaries(true)
try {
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
@@ -39,7 +41,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
const loadSelectedDictionaries = async () => {
if (initialEditingConfig?.id) {
try {
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
const response = await authFetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
if (response.ok) {
const data = await response.json()
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
@@ -100,7 +102,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
: `${API_URL}/configs`
const method = initialEditingConfig ? 'PUT' : 'POST'
const response = await fetch(url, {
const response = await authFetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react'
import { useAuth } from './auth/AuthContext'
import './AddWords.css'
const API_URL = '/api'
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
const { authFetch } = useAuth()
const [markdownText, setMarkdownText] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
@@ -81,7 +83,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
}))
const response = await fetch(`${API_URL}/words`, {
const response = await authFetch(`${API_URL}/words`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,57 +0,0 @@
import React, { useState } from 'react'
import TodoistIntegration from './TodoistIntegration'
import TelegramIntegration from './TelegramIntegration'
function Integrations({ onNavigate }) {
const [selectedIntegration, setSelectedIntegration] = useState(null)
const integrations = [
{ id: 'todoist', name: 'TODOist' },
{ id: 'telegram', name: 'Telegram' },
]
if (selectedIntegration) {
if (selectedIntegration === 'todoist') {
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
} else if (selectedIntegration === 'telegram') {
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
}
}
return (
<div className="p-4 md:p-6">
<h1 className="text-2xl font-bold mb-6">Интеграции</h1>
<div className="space-y-4">
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => setSelectedIntegration(integration.id)}
className="w-full p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow text-left border border-gray-200 hover:border-indigo-300"
>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-gray-800">
{integration.name}
</span>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
))}
</div>
</div>
)
}
export default Integrations

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react'
import { useAuth } from './auth/AuthContext'
import TodoistIntegration from './TodoistIntegration'
import TelegramIntegration from './TelegramIntegration'
function Profile({ onNavigate }) {
const { user, logout } = useAuth()
const [selectedIntegration, setSelectedIntegration] = useState(null)
const integrations = [
{ id: 'todoist', name: 'TODOist', icon: '✓' },
{ id: 'telegram', name: 'Telegram', icon: '✈️' },
]
const handleLogout = async () => {
if (window.confirm('Вы уверены, что хотите выйти?')) {
await logout()
}
}
if (selectedIntegration) {
if (selectedIntegration === 'todoist') {
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
} else if (selectedIntegration === 'telegram') {
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
}
}
return (
<div className="p-4 md:p-6 max-w-2xl mx-auto">
{/* Profile Header */}
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 mb-6 text-white shadow-lg">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center text-2xl font-bold backdrop-blur-sm">
{user?.name ? user.name.charAt(0).toUpperCase() : user?.email?.charAt(0).toUpperCase() || '?'}
</div>
<div className="flex-1">
<h1 className="text-xl font-bold">
{user?.name || 'Пользователь'}
</h1>
<p className="text-indigo-100 text-sm">
{user?.email}
</p>
</div>
</div>
</div>
{/* Integrations Section */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
<div className="space-y-3">
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => setSelectedIntegration(integration.id)}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">{integration.icon}</span>
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
{integration.name}
</span>
</div>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
))}
</div>
</div>
{/* Account Section */}
<div>
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Аккаунт</h2>
<button
onClick={handleLogout}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-red-200 group"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">🚪</span>
<span className="text-gray-800 font-medium group-hover:text-red-600 transition-colors">
Выйти из аккаунта
</span>
</div>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</div>
</button>
</div>
{/* Version Info */}
<div className="mt-8 text-center text-gray-400 text-sm">
<p>Play Life</p>
</div>
</div>
)
}
export default Profile

View File

@@ -19,6 +19,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { useAuth } from './auth/AuthContext'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects'
@@ -46,7 +47,7 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
try {
const projectId = project.id ?? project.name
const response = await fetch(PROJECT_MOVE_API_URL, {
const response = await authFetch(PROJECT_MOVE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -263,6 +264,7 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu
}
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
const { authFetch } = useAuth()
const [projectsLoading, setProjectsLoading] = useState(false)
const [projectsError, setProjectsError] = useState(null)
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
@@ -381,7 +383,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
}
setProjectsError(null)
const response = await fetch(PROJECTS_API_URL)
const response = await authFetch(PROJECTS_API_URL)
if (!response.ok) {
throw new Error('Не удалось загрузить проекты')
}
@@ -483,7 +485,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
const sendPriorityChanges = useCallback(async (changes) => {
if (!changes.length) return
try {
await fetch(PRIORITY_UPDATE_API_URL, {
await authFetch(PRIORITY_UPDATE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
@@ -723,7 +725,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
try {
const projectId = selectedProject.id ?? selectedProject.name
const response = await fetch(`/project/delete`, {
const response = await authFetch(`/project/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId }),

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './Integrations.css'
function TelegramIntegration({ onBack }) {
const { authFetch } = useAuth()
const [botToken, setBotToken] = useState('')
const [chatId, setChatId] = useState('')
const [loading, setLoading] = useState(true)
@@ -16,7 +18,7 @@ function TelegramIntegration({ onBack }) {
const fetchIntegration = async () => {
try {
setLoading(true)
const response = await fetch('/api/integrations/telegram')
const response = await authFetch('/api/integrations/telegram')
if (!response.ok) {
throw new Error('Ошибка при загрузке интеграции')
}
@@ -42,7 +44,7 @@ function TelegramIntegration({ onBack }) {
setError('')
setSuccess('')
const response = await fetch('/api/integrations/telegram', {
const response = await authFetch('/api/integrations/telegram', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './TestConfigSelection.css'
const API_URL = '/api'
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [configs, setConfigs] = useState([])
const [dictionaries, setDictionaries] = useState([])
const [loading, setLoading] = useState(true)
@@ -38,7 +40,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
setLoading(true)
}
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке конфигураций и словарей')
}
@@ -92,7 +94,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
if (!selectedDictionary) return
try {
const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
@@ -119,7 +121,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
if (!selectedConfig) return
try {
const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, {
const response = await authFetch(`${API_URL}/configs/${selectedConfig.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './TestWords.css'
const API_URL = '/api'
@@ -6,6 +7,7 @@ const API_URL = '/api'
const DEFAULT_TEST_WORD_COUNT = 10
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
const { authFetch } = useAuth()
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
const configId = initialConfigId || null
const maxCards = initialMaxCards || null
@@ -49,7 +51,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
throw new Error('config_id обязателен для запуска теста')
}
const url = `${API_URL}/test/words?config_id=${configId}`
const response = await fetch(url)
const response = await authFetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке слов')
}
@@ -176,7 +178,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
requestBody
})
const response = await fetch(`${API_URL}/test/progress`, {
const response = await authFetch(`${API_URL}/test/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './Integrations.css'
function TodoistIntegration({ onBack }) {
const { authFetch } = useAuth()
const [webhookURL, setWebhookURL] = useState('')
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
@@ -13,7 +15,7 @@ function TodoistIntegration({ onBack }) {
const fetchWebhookURL = async () => {
try {
setLoading(true)
const response = await fetch('/api/integrations/todoist/webhook-url')
const response = await authFetch('/api/integrations/todoist/webhook-url')
if (!response.ok) {
throw new Error('Ошибка при загрузке URL webhook')
}

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './WordList.css'
const API_URL = '/api'
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [words, setWords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -44,7 +46,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
const fetchDictionary = async (dictId) => {
try {
const response = await fetch(`${API_URL}/dictionaries`)
const response = await authFetch(`${API_URL}/dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
@@ -74,7 +76,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
try {
setLoading(true)
const url = `${API_URL}/words?dictionary_id=${dictId}`
const response = await fetch(url)
const response = await authFetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке слов')
}
@@ -102,7 +104,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
try {
if (!hasValidDictionary(currentDictionaryId)) {
// Create new dictionary
const response = await fetch(`${API_URL}/dictionaries`, {
const response = await authFetch(`${API_URL}/dictionaries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -131,7 +133,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
onNavigate?.('words', { dictionaryId: newDictionaryId })
} else if (hasValidDictionary(currentDictionaryId)) {
// Update existing dictionary (rename)
const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',

View File

@@ -0,0 +1,230 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
const AuthContext = createContext(null)
const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
const USER_KEY = 'user'
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Initialize from localStorage
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem(TOKEN_KEY)
const savedUser = localStorage.getItem(USER_KEY)
if (token && savedUser) {
try {
setUser(JSON.parse(savedUser))
// Verify token is still valid
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
if (response.ok) {
const data = await response.json()
setUser(data.user)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
} else if (response.status === 401) {
// Try to refresh token
const refreshed = await refreshToken()
if (!refreshed) {
logout()
}
}
} catch (err) {
console.error('Auth init error:', err)
logout()
}
}
setLoading(false)
}
initAuth()
}, [])
const login = useCallback(async (email, password) => {
setError(null)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Ошибка входа')
}
localStorage.setItem(TOKEN_KEY, data.access_token)
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
setUser(data.user)
return true
} catch (err) {
setError(err.message)
return false
}
}, [])
const register = useCallback(async (email, password, name) => {
setError(null)
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password, name: name || undefined })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Ошибка регистрации')
}
localStorage.setItem(TOKEN_KEY, data.access_token)
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
setUser(data.user)
return true
} catch (err) {
setError(err.message)
return false
}
}, [])
const logout = useCallback(async () => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
} catch (err) {
console.error('Logout error:', err)
}
}
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
setUser(null)
}, [])
const refreshToken = useCallback(async () => {
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY)
if (!refresh) {
return false
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refresh })
})
if (!response.ok) {
return false
}
const data = await response.json()
localStorage.setItem(TOKEN_KEY, data.access_token)
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
setUser(data.user)
return true
} catch (err) {
console.error('Refresh token error:', err)
return false
}
}, [])
const getToken = useCallback(() => {
return localStorage.getItem(TOKEN_KEY)
}, [])
// Fetch wrapper that handles auth
const authFetch = useCallback(async (url, options = {}) => {
const token = localStorage.getItem(TOKEN_KEY)
const headers = {
'Content-Type': 'application/json',
...options.headers
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let response = await fetch(url, { ...options, headers })
// If 401, try to refresh token and retry
if (response.status === 401) {
const refreshed = await refreshToken()
if (refreshed) {
const newToken = localStorage.getItem(TOKEN_KEY)
headers['Authorization'] = `Bearer ${newToken}`
response = await fetch(url, { ...options, headers })
} else {
logout()
}
}
return response
}, [refreshToken, logout])
const value = {
user,
loading,
error,
login,
register,
logout,
getToken,
authFetch,
isAuthenticated: !!user
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export default AuthContext

View File

@@ -0,0 +1,16 @@
import React, { useState } from 'react'
import LoginForm from './LoginForm'
import RegisterForm from './RegisterForm'
function AuthScreen() {
const [mode, setMode] = useState('login') // 'login' or 'register'
if (mode === 'register') {
return <RegisterForm onSwitchToLogin={() => setMode('login')} />
}
return <LoginForm onSwitchToRegister={() => setMode('register')} />
}
export default AuthScreen

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react'
import { useAuth } from './AuthContext'
function LoginForm({ onSwitchToRegister }) {
const { login, error } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [localError, setLocalError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setLocalError('')
if (!email.trim()) {
setLocalError('Введите email')
return
}
if (!password) {
setLocalError('Введите пароль')
return
}
setLoading(true)
const success = await login(email, password)
setLoading(false)
if (!success) {
setLocalError(error || 'Ошибка входа')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
<p className="text-gray-300">Войдите в свой аккаунт</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="••••••••"
autoComplete="current-password"
/>
</div>
{(localError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
{localError || error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Вход...
</span>
) : 'Войти'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-gray-400">
Нет аккаунта?{' '}
<button
onClick={onSwitchToRegister}
className="text-purple-400 hover:text-purple-300 font-medium transition"
>
Зарегистрироваться
</button>
</p>
</div>
</div>
</div>
</div>
)
}
export default LoginForm

View File

@@ -0,0 +1,150 @@
import React, { useState } from 'react'
import { useAuth } from './AuthContext'
function RegisterForm({ onSwitchToLogin }) {
const { register, error } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [name, setName] = useState('')
const [loading, setLoading] = useState(false)
const [localError, setLocalError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setLocalError('')
if (!email.trim()) {
setLocalError('Введите email')
return
}
if (!password) {
setLocalError('Введите пароль')
return
}
if (password.length < 6) {
setLocalError('Пароль должен быть не менее 6 символов')
return
}
if (password !== confirmPassword) {
setLocalError('Пароли не совпадают')
return
}
setLoading(true)
const success = await register(email, password, name || undefined)
setLoading(false)
if (!success) {
setLocalError(error || 'Ошибка регистрации')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
<p className="text-gray-300">Создайте аккаунт</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Имя <span className="text-gray-500">(необязательно)</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="Ваше имя"
autoComplete="name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="Минимум 6 символов"
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Подтвердите пароль
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="Повторите пароль"
autoComplete="new-password"
/>
</div>
{(localError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
{localError || error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Регистрация...
</span>
) : 'Зарегистрироваться'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-gray-400">
Уже есть аккаунт?{' '}
<button
onClick={onSwitchToLogin}
className="text-purple-400 hover:text-purple-300 font-medium transition"
>
Войти
</button>
</p>
</div>
</div>
</div>
</div>
)
}
export default RegisterForm

View File

@@ -100,15 +100,24 @@ echo " База: $DB_NAME"
echo " Хост: $DB_HOST:$DB_PORT"
echo " Файл: $FULL_DUMP_PATH"
# Распаковываем, если сжат
# Распаковываем и модифицируем дамп
TEMP_DUMP="/tmp/restore_$$.sql"
if [[ "$FULL_DUMP_PATH" == *.gz ]]; then
echo " Распаковка дампа..."
gunzip -c "$FULL_DUMP_PATH" > "$TEMP_DUMP"
echo " Распаковка и модификация дампа..."
gunzip -c "$FULL_DUMP_PATH" | \
sed 's/n8n_user/'"$DB_USER"'/g' | \
sed '/^\\restrict/d' | \
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
else
cp "$FULL_DUMP_PATH" "$TEMP_DUMP"
echo " Модификация дампа..."
cat "$FULL_DUMP_PATH" | \
sed 's/n8n_user/'"$DB_USER"'/g' | \
sed '/^\\restrict/d' | \
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
fi
echo " Владелец таблиц в дампе заменён на: $DB_USER"
# Восстанавливаем через docker-compose, если контейнер запущен
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
echo " Используется docker-compose..."
@@ -132,5 +141,33 @@ fi
# Удаляем временный файл
rm -f "$TEMP_DUMP"
echo "✅ База данных успешно восстановлена из дампа!"
echo ""
echo "📦 Применение миграций для добавления user_id..."
# Определяем путь к миграциям
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MIGRATIONS_DIR="$SCRIPT_DIR/play-life-backend/migrations"
if [ -d "$MIGRATIONS_DIR" ]; then
# Применяем миграцию 009 для добавления user_id
MIGRATION_FILE="$MIGRATIONS_DIR/009_add_users_and_multitenancy.sql"
if [ -f "$MIGRATION_FILE" ]; then
echo " Применяем миграцию: 009_add_users_and_multitenancy.sql"
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
docker-compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true
elif command -v psql &> /dev/null; then
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true
fi
echo " ✅ Миграция применена"
fi
else
echo " ⚠️ Директория миграций не найдена: $MIGRATIONS_DIR"
echo " Миграции будут применены при запуске бэкенда"
fi
echo ""
echo "✅ База данных успешно восстановлена из дампа!"
echo ""
echo "📌 ВАЖНО: После первой регистрации/входа пользователя все данные"
echo " будут автоматически привязаны к этому пользователю."