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:
542
src/config/adminjs-simple.js
Normal file
542
src/config/adminjs-simple.js
Normal 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 };
|
||||
33
src/config/database.js
Normal file
33
src/config/database.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import pkg from 'pg';
|
||||
const { Pool } = pkg;
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
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',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
// Test database connection
|
||||
pool.on('connect', () => {
|
||||
console.log('💾 Connected to PostgreSQL database');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('🔴 Database connection error:', err);
|
||||
});
|
||||
|
||||
const db = {
|
||||
pool,
|
||||
query: (text, params) => pool.query(text, params)
|
||||
};
|
||||
|
||||
export default db;
|
||||
export { pool };
|
||||
Reference in New Issue
Block a user