Initial commit: Korea Tourism Agency website with AdminJS

- Full-stack Node.js/Express application with PostgreSQL
- Modern ES modules architecture
- AdminJS admin panel with Sequelize ORM
- Tourism routes, guides, articles, bookings management
- Responsive Bootstrap 5 frontend
- Docker containerization with docker-compose
- Complete database schema with migrations
- Authentication system for admin panel
- Dynamic placeholder images for tour categories
This commit is contained in:
2025-11-29 18:13:17 +09:00
commit 409e6c146b
53 changed files with 16195 additions and 0 deletions

View File

@@ -0,0 +1,542 @@
import AdminJS from 'adminjs';
import AdminJSExpress from '@adminjs/express';
import AdminJSSequelize from '@adminjs/sequelize';
import bcrypt from 'bcryptjs';
import pkg from 'pg';
import { Sequelize, DataTypes } from 'sequelize';
const { Pool } = pkg;
// Регистрируем адаптер Sequelize
AdminJS.registerAdapter(AdminJSSequelize);
// Создаем подключение Sequelize
const sequelize = new Sequelize(
process.env.DB_NAME || 'korea_tourism',
process.env.DB_USER || 'tourism_user',
process.env.DB_PASSWORD || 'tourism_password',
{
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
logging: false,
}
);
// Создаем пул подключений для аутентификации (отдельно от Sequelize)
const authPool = new Pool({
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'korea_tourism',
user: process.env.DB_USER || 'tourism_user',
password: process.env.DB_PASSWORD || 'tourism_password',
});
// Определяем модели Sequelize
const Routes = sequelize.define('routes', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
title: { type: DataTypes.STRING, allowNull: false },
description: { type: DataTypes.TEXT },
content: { type: DataTypes.TEXT },
type: { type: DataTypes.ENUM('city', 'mountain', 'fishing') },
difficulty_level: { type: DataTypes.ENUM('easy', 'moderate', 'hard') },
price: { type: DataTypes.DECIMAL(10, 2) },
duration: { type: DataTypes.INTEGER },
max_group_size: { type: DataTypes.INTEGER },
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'routes'
});
const Guides = sequelize.define('guides', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING },
phone: { type: DataTypes.STRING },
languages: { type: DataTypes.TEXT },
specialization: { type: DataTypes.ENUM('city', 'mountain', 'fishing', 'general') },
bio: { type: DataTypes.TEXT },
experience: { type: DataTypes.INTEGER },
hourly_rate: { type: DataTypes.DECIMAL(10, 2) },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'guides'
});
const Articles = sequelize.define('articles', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
title: { type: DataTypes.STRING, allowNull: false },
excerpt: { type: DataTypes.TEXT },
content: { type: DataTypes.TEXT, allowNull: false },
category: { type: DataTypes.ENUM('travel-tips', 'culture', 'food', 'nature', 'history') },
is_published: { type: DataTypes.BOOLEAN, defaultValue: false },
views: { type: DataTypes.INTEGER, defaultValue: 0 },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'articles'
});
const Bookings = sequelize.define('bookings', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
customer_name: { type: DataTypes.STRING, allowNull: false },
customer_email: { type: DataTypes.STRING, allowNull: false },
customer_phone: { type: DataTypes.STRING },
preferred_date: { type: DataTypes.DATE, allowNull: false },
group_size: { type: DataTypes.INTEGER, allowNull: false },
status: { type: DataTypes.ENUM('pending', 'confirmed', 'cancelled', 'completed'), defaultValue: 'pending' },
total_price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
notes: { type: DataTypes.TEXT },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'bookings'
});
const Reviews = sequelize.define('reviews', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
customer_name: { type: DataTypes.STRING, allowNull: false },
customer_email: { type: DataTypes.STRING },
rating: { type: DataTypes.INTEGER, validate: { min: 1, max: 5 } },
comment: { type: DataTypes.TEXT },
is_approved: { type: DataTypes.BOOLEAN, defaultValue: false },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'reviews'
});
const ContactMessages = sequelize.define('contact_messages', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
phone: { type: DataTypes.STRING },
subject: { type: DataTypes.STRING, allowNull: false },
message: { type: DataTypes.TEXT, allowNull: false },
status: { type: DataTypes.ENUM('unread', 'read', 'replied'), defaultValue: 'unread' },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'contact_messages'
});
const Admins = sequelize.define('admins', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
username: { type: DataTypes.STRING, allowNull: false, unique: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
password: { type: DataTypes.STRING, allowNull: false },
role: { type: DataTypes.ENUM('admin', 'manager', 'editor'), defaultValue: 'admin' },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
last_login: { type: DataTypes.DATE },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'admins'
});
// Конфигурация AdminJS с ресурсами базы данных
// Конфигурация AdminJS с ресурсами Sequelize
const adminJsOptions = {
resources: [
{
resource: Routes,
options: {
parent: { name: 'Контент', icon: 'DocumentText' },
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active'],
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active', 'created_at', 'updated_at'],
filterProperties: ['title', 'type', 'is_active'],
properties: {
title: {
isTitle: true,
isRequired: true,
},
description: {
type: 'textarea',
isRequired: true,
},
content: {
type: 'textarea',
},
type: {
availableValues: [
{ value: 'city', label: 'Городской тур' },
{ value: 'mountain', label: 'Горный поход' },
{ value: 'fishing', label: 'Рыбалка' }
],
},
difficulty_level: {
availableValues: [
{ value: 'easy', label: 'Легкий' },
{ value: 'moderate', label: 'Средний' },
{ value: 'hard', label: 'Сложный' }
],
},
price: {
type: 'number',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
max_group_size: {
type: 'number',
isRequired: true,
},
is_featured: { type: 'boolean' },
is_active: { type: 'boolean' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
},
updated_at: {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
}
},
{
resource: Guides,
options: {
parent: { name: 'Персонал', icon: 'Users' },
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active'],
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active', 'created_at'],
filterProperties: ['name', 'specialization', 'is_active'],
properties: {
name: {
isTitle: true,
isRequired: true,
},
email: {
type: 'email',
isRequired: true,
},
phone: { type: 'string' },
languages: {
type: 'textarea',
description: 'Языки через запятую',
},
specialization: {
availableValues: [
{ value: 'city', label: 'Городские туры' },
{ value: 'mountain', label: 'Горные походы' },
{ value: 'fishing', label: 'Рыбалка' },
{ value: 'general', label: 'Универсальный' }
],
},
bio: { type: 'textarea' },
experience: {
type: 'number',
description: 'Опыт работы в годах',
},
hourly_rate: {
type: 'number',
description: 'Ставка за час в вонах',
},
is_active: { type: 'boolean' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
},
{
resource: Articles,
options: {
parent: { name: 'Контент', icon: 'DocumentText' },
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
editProperties: ['title', 'excerpt', 'content', 'category', 'is_published'],
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
filterProperties: ['title', 'category', 'is_published'],
properties: {
title: {
isTitle: true,
isRequired: true,
},
excerpt: {
type: 'textarea',
description: 'Краткое описание статьи',
},
content: {
type: 'textarea',
isRequired: true,
},
category: {
availableValues: [
{ value: 'travel-tips', label: 'Советы путешественникам' },
{ value: 'culture', label: 'Культура' },
{ value: 'food', label: 'Еда' },
{ value: 'nature', label: 'Природа' },
{ value: 'history', label: 'История' }
],
},
is_published: { type: 'boolean' },
views: {
type: 'number',
isVisible: { list: true, filter: true, show: true, edit: false },
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
},
updated_at: {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
}
},
{
resource: Bookings,
options: {
parent: { name: 'Заказы', icon: 'ShoppingCart' },
listProperties: ['id', 'customer_name', 'customer_email', 'preferred_date', 'status', 'total_price', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes'],
showProperties: ['id', 'customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes', 'created_at'],
filterProperties: ['customer_name', 'customer_email', 'status', 'preferred_date'],
properties: {
customer_name: {
isTitle: true,
isRequired: true,
},
customer_email: {
type: 'email',
isRequired: true,
},
customer_phone: { type: 'string' },
preferred_date: {
type: 'date',
isRequired: true,
},
group_size: {
type: 'number',
isRequired: true,
},
status: {
availableValues: [
{ value: 'pending', label: 'В ожидании' },
{ value: 'confirmed', label: 'Подтверждено' },
{ value: 'cancelled', label: 'Отменено' },
{ value: 'completed', label: 'Завершено' }
],
},
total_price: {
type: 'number',
isRequired: true,
},
notes: { type: 'textarea' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
},
{
resource: Reviews,
options: {
parent: { name: 'Отзывы', icon: 'Star' },
listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
filterProperties: ['customer_name', 'rating', 'is_approved'],
properties: {
customer_name: {
isTitle: true,
isRequired: true,
},
customer_email: { type: 'email' },
rating: {
type: 'number',
availableValues: [
{ value: 1, label: '1 звезда' },
{ value: 2, label: '2 звезды' },
{ value: 3, label: '3 звезды' },
{ value: 4, label: '4 звезды' },
{ value: 5, label: '5 звезд' }
]
},
comment: { type: 'textarea' },
is_approved: { type: 'boolean' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
},
{
resource: ContactMessages,
options: {
parent: { name: 'Сообщения', icon: 'Email' },
listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
filterProperties: ['name', 'email', 'status'],
properties: {
name: {
isTitle: true,
isRequired: true,
},
email: {
type: 'email',
isRequired: true,
},
phone: { type: 'string' },
subject: {
type: 'string',
isRequired: true,
},
message: {
type: 'textarea',
isRequired: true,
},
status: {
availableValues: [
{ value: 'unread', label: 'Не прочитано' },
{ value: 'read', label: 'Прочитано' },
{ value: 'replied', label: 'Отвечено' }
],
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
actions: {
new: { isAccessible: false },
edit: { isAccessible: true },
delete: { isAccessible: true },
list: { isAccessible: true },
show: { isAccessible: true }
}
}
},
{
resource: Admins,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
editProperties: ['username', 'name', 'email', 'role', 'is_active'],
showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
filterProperties: ['username', 'name', 'role', 'is_active'],
properties: {
username: {
isTitle: true,
isRequired: true,
},
name: {
type: 'string',
isRequired: true,
},
email: {
type: 'email',
isRequired: true,
},
password: {
type: 'password',
isVisible: { list: false, filter: false, show: false, edit: true }
},
role: {
availableValues: [
{ value: 'admin', label: 'Администратор' },
{ value: 'manager', label: 'Менеджер' },
{ value: 'editor', label: 'Редактор' }
],
},
is_active: { type: 'boolean' },
last_login: {
isVisible: { list: false, filter: false, show: true, edit: false },
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
}
],
rootPath: '/admin',
branding: {
companyName: 'Korea Tourism Agency',
softwareBrothers: false,
theme: {
colors: {
primary100: '#ff6b6b',
primary80: '#ff5252',
primary60: '#ff3d3d',
primary40: '#ff2828',
primary20: '#ff1313',
grey100: '#151515',
grey80: '#333333',
grey60: '#666666',
grey40: '#999999',
grey20: '#cccccc',
filterBg: '#333333',
accent: '#38C172',
hoverBg: '#f0f0f0',
},
},
},
dashboard: {
component: false
}
};
// Создаем экземпляр AdminJS
const adminJs = new AdminJS(adminJsOptions);
// Настраиваем аутентификацию
const router = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
authenticate: async (email, password) => {
try {
console.log('Attempting login for:', email);
const result = await authPool.query(
'SELECT * FROM admins WHERE username = $1 AND is_active = true',
[email]
);
if (result.rows.length === 0) {
console.log('No admin found with username:', email);
return null;
}
const admin = result.rows[0];
console.log('Admin found:', admin.name);
const isValid = await bcrypt.compare(password, admin.password);
if (isValid) {
console.log('Authentication successful for:', email);
return {
id: admin.id,
email: admin.username,
title: admin.name,
role: admin.role
};
}
console.log('Invalid password for:', email);
return null;
} catch (error) {
console.error('Auth error:', error);
return null;
}
},
cookiePassword: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024'
}, null, {
resave: false,
saveUninitialized: false,
secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 часа
}
});
export { adminJs, router };