🎯 프로젝트 리브랜딩: Kumamoto → Travel Planner v2.0
✨ 주요 변경사항: - 프로젝트 이름: kumamoto-travel-planner → travel-planner - 버전 업그레이드: v1.0.0 → v2.0.0 - 멀티유저 시스템 구현 (JWT 인증) - PostgreSQL 마이그레이션 시스템 추가 - Docker 컨테이너 이름 변경 - UI 브랜딩 업데이트 (Travel Planner) - API 서버 및 인증 시스템 추가 - 여행 공유 기능 구현 - 템플릿 시스템 추가 🔧 기술 스택: - Frontend: React + TypeScript + Vite - Backend: Node.js + Express + JWT - Database: PostgreSQL + 마이그레이션 - Infrastructure: Docker + Docker Compose 🌟 새로운 기능: - 사용자 인증 및 권한 관리 - 다중 여행 계획 관리 - 여행 템플릿 시스템 - 공유 링크 및 댓글 시스템 - 관리자 대시보드
This commit is contained in:
27
server/Dockerfile
Normal file
27
server/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# API Server Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 패키지 파일 복사
|
||||
COPY package*.json ./
|
||||
|
||||
# 의존성 설치
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 소스 코드 복사
|
||||
COPY . .
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
RUN mkdir -p uploads
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# 서버 시작
|
||||
CMD ["npm", "start"]
|
||||
45
server/db.js
Normal file
45
server/db.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// PostgreSQL 연결 풀 생성
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
// 데이터베이스 초기화 (테이블 생성)
|
||||
async function initializeDatabase() {
|
||||
try {
|
||||
// v2 스키마 사용 (새로운 멀티유저 시스템)
|
||||
const schemaPath = fs.existsSync(path.join(__dirname, 'schema_v2.sql'))
|
||||
? 'schema_v2.sql'
|
||||
: 'schema.sql';
|
||||
|
||||
const schema = fs.readFileSync(path.join(__dirname, schemaPath), 'utf8');
|
||||
await pool.query(schema);
|
||||
console.log(`✅ Database tables initialized successfully (${schemaPath})`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error initializing database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 헬퍼 함수
|
||||
async function query(text, params) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await pool.query(text, params);
|
||||
const duration = Date.now() - start;
|
||||
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Query error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
pool,
|
||||
initializeDatabase,
|
||||
};
|
||||
18
server/env.example
Normal file
18
server/env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/kumamoto_travel
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
||||
# Google Maps API (Optional)
|
||||
GOOGLE_MAPS_API_KEY=your-google-maps-api-key
|
||||
|
||||
# Email Configuration (Optional)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
80
server/migrate.js
Normal file
80
server/migrate.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const { query } = require('./db');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('🔄 데이터베이스 마이그레이션 시작...');
|
||||
|
||||
// 마이그레이션 디렉토리 확인
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
if (!fs.existsSync(migrationsDir)) {
|
||||
console.log('📁 마이그레이션 디렉토리가 없습니다. 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 마이그레이션 파일 목록
|
||||
const migrationFiles = fs.readdirSync(migrationsDir)
|
||||
.filter(file => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
if (migrationFiles.length === 0) {
|
||||
console.log('📄 실행할 마이그레이션이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 마이그레이션 테이블 생성
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 각 마이그레이션 실행
|
||||
for (const filename of migrationFiles) {
|
||||
// 이미 실행된 마이그레이션인지 확인
|
||||
const existingResult = await query(
|
||||
'SELECT id FROM migrations WHERE filename = $1',
|
||||
[filename]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
console.log(`⏭️ ${filename} - 이미 실행됨`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`🔧 ${filename} 실행 중...`);
|
||||
|
||||
// 마이그레이션 파일 읽기 및 실행
|
||||
const migrationPath = path.join(migrationsDir, filename);
|
||||
const migrationSQL = fs.readFileSync(migrationPath, 'utf8');
|
||||
|
||||
await query(migrationSQL);
|
||||
|
||||
// 마이그레이션 기록
|
||||
await query(
|
||||
'INSERT INTO migrations (filename) VALUES ($1)',
|
||||
[filename]
|
||||
);
|
||||
|
||||
console.log(`✅ ${filename} 완료`);
|
||||
}
|
||||
|
||||
console.log('🎉 모든 마이그레이션이 완료되었습니다!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 마이그레이션 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 직접 실행 시
|
||||
if (require.main === module) {
|
||||
runMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
129
server/migrations/001_add_user_system.sql
Normal file
129
server/migrations/001_add_user_system.sql
Normal file
@@ -0,0 +1,129 @@
|
||||
-- 기존 DB에 사용자 시스템 추가 마이그레이션
|
||||
|
||||
-- 사용자 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP
|
||||
);
|
||||
|
||||
-- 기존 travel_plans 테이블에 컬럼 추가 (안전하게 하나씩)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='user_id') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='title') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN title VARCHAR(255) DEFAULT 'Untitled Trip';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='description') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN description TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='destination') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN destination VARCHAR(255) DEFAULT 'Kumamoto';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='is_public') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN is_public BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='is_template') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN is_template BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='template_category') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN template_category VARCHAR(20);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='tags') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN tags TEXT[] DEFAULT '{}';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='status') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN status VARCHAR(20) DEFAULT 'draft';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 공유 링크 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||
share_code VARCHAR(8) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
max_access_count INTEGER,
|
||||
can_view BOOLEAN DEFAULT true,
|
||||
can_edit BOOLEAN DEFAULT false,
|
||||
can_comment BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TIMESTAMP
|
||||
);
|
||||
|
||||
-- 댓글 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS trip_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
is_edited BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 시스템 설정 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_user ON travel_plans(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_status ON travel_plans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_code ON share_links(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_trip ON share_links(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_trip ON trip_comments(trip_id);
|
||||
|
||||
-- 초기 시스템 설정
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('app_version', '2.0.0', '애플리케이션 버전'),
|
||||
('setup_completed', 'false', '초기 설정 완료 여부'),
|
||||
('jwt_secret_set', 'false', 'JWT 시크릿 설정 여부')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- 업데이트 트리거 함수
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 업데이트 트리거 적용
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_travel_plans_updated_at ON travel_plans;
|
||||
CREATE TRIGGER update_travel_plans_updated_at BEFORE UPDATE ON travel_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_comments_updated_at ON trip_comments;
|
||||
CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON trip_comments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_settings_updated_at ON system_settings;
|
||||
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON system_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
2154
server/package-lock.json
generated
Normal file
2154
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
server/package.json
Normal file
22
server/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "travel-planner-api",
|
||||
"version": "2.0.0",
|
||||
"description": "Backend API for Travel Planner - Multi-user travel planning system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server.js",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
179
server/routes/auth.js
Normal file
179
server/routes/auth.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// JWT 시크릿 (환경변수에서 가져오거나 기본값 사용)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
|
||||
|
||||
// 회원가입
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingUser = await query('SELECT id FROM users WHERE email = $1', [email]);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({ success: false, message: '이미 사용 중인 이메일입니다' });
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// 사용자 생성
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name, role, created_at',
|
||||
[email, passwordHash, name]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
created_at: user.created_at
|
||||
},
|
||||
token,
|
||||
message: '회원가입이 완료되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// 사용자 조회
|
||||
const result = await query(
|
||||
'SELECT id, email, password_hash, name, role, is_active FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(400).json({ success: false, message: '등록되지 않은 이메일입니다' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(400).json({ success: false, message: '비활성화된 계정입니다' });
|
||||
}
|
||||
|
||||
// 비밀번호 확인
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({ success: false, message: '비밀번호가 올바르지 않습니다' });
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
await query('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', [user.id]);
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
is_active: user.is_active
|
||||
},
|
||||
token,
|
||||
message: '로그인되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: '로그인 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
// 토큰 검증
|
||||
router.get('/verify', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, is_active, last_login FROM users WHERE id = $1',
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ success: false, message: '비활성화된 계정입니다' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
res.status(500).json({ success: false, message: '토큰 검증 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
// JWT 토큰 인증 미들웨어
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, message: '액세스 토큰이 필요합니다' });
|
||||
}
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ success: false, message: '유효하지 않은 토큰입니다' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// 관리자 권한 확인 미들웨어
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, message: '관리자 권한이 필요합니다' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
router,
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
};
|
||||
77
server/routes/basePoints.js
Normal file
77
server/routes/basePoints.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const express = require('express');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 기본 포인트 조회
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'SELECT * FROM base_points ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
const basePoints = result.rows.map(row => ({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
type: row.type,
|
||||
coordinates: {
|
||||
lat: parseFloat(row.lat),
|
||||
lng: parseFloat(row.lng)
|
||||
},
|
||||
memo: row.memo
|
||||
}));
|
||||
|
||||
res.json(basePoints);
|
||||
} catch (error) {
|
||||
console.error('Error fetching base points:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 기본 포인트 추가
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, address, type, coordinates, memo } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO base_points (name, address, type, lat, lng, memo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, address || null, type, coordinates.lat, coordinates.lng, memo || null]
|
||||
);
|
||||
|
||||
const basePoint = {
|
||||
id: result.rows[0].id.toString(),
|
||||
name: result.rows[0].name,
|
||||
address: result.rows[0].address,
|
||||
type: result.rows[0].type,
|
||||
coordinates: {
|
||||
lat: parseFloat(result.rows[0].lat),
|
||||
lng: parseFloat(result.rows[0].lng)
|
||||
},
|
||||
memo: result.rows[0].memo
|
||||
};
|
||||
|
||||
res.json(basePoint);
|
||||
} catch (error) {
|
||||
console.error('Error creating base point:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 기본 포인트 삭제
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await query('DELETE FROM base_points WHERE id = $1', [id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting base point:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
187
server/routes/setup.js
Normal file
187
server/routes/setup.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 설정 상태 확인
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
// 시스템 설정 확인
|
||||
const settingsResult = await query('SELECT key, value FROM system_settings');
|
||||
const settings = {};
|
||||
settingsResult.rows.forEach(row => {
|
||||
settings[row.key] = row.value;
|
||||
});
|
||||
|
||||
// 관리자 계정 존재 여부 확인
|
||||
const adminResult = await query('SELECT COUNT(*) as count FROM users WHERE role = $1', ['admin']);
|
||||
const hasAdmin = parseInt(adminResult.rows[0].count) > 0;
|
||||
|
||||
// 전체 사용자 수
|
||||
const userCountResult = await query('SELECT COUNT(*) as count FROM users');
|
||||
const totalUsers = parseInt(userCountResult.rows[0].count);
|
||||
|
||||
const isSetupComplete = settings.setup_completed === 'true' && hasAdmin;
|
||||
|
||||
res.json({
|
||||
isSetupComplete,
|
||||
is_setup_required: !isSetupComplete,
|
||||
setup_step: isSetupComplete ? 'completed' : 'initial',
|
||||
has_admin: hasAdmin,
|
||||
total_users: totalUsers,
|
||||
version: settings.app_version || '2.0.0',
|
||||
settings: {
|
||||
jwt_secret_set: settings.jwt_secret_set === 'true',
|
||||
google_maps_configured: settings.google_maps_configured === 'true',
|
||||
email_configured: settings.email_configured === 'true'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Setup status check error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '설정 상태 확인 중 오류가 발생했습니다',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 관리자 계정 생성
|
||||
router.post('/admin', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
// 입력 검증
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이름, 이메일, 비밀번호를 모두 입력해주세요'
|
||||
});
|
||||
}
|
||||
|
||||
// 이미 관리자가 있는지 확인
|
||||
const existingAdmin = await query('SELECT id FROM users WHERE role = $1', ['admin']);
|
||||
if (existingAdmin.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 관리자 계정이 존재합니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingUser = await query('SELECT id FROM users WHERE email = $1', [email]);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 사용 중인 이메일입니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// 관리자 계정 생성
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, password_hash, name, role) VALUES ($1, $2, $3, $4) RETURNING id, email, name, role, created_at',
|
||||
[email, passwordHash, name, 'admin']
|
||||
);
|
||||
|
||||
const admin = result.rows[0];
|
||||
|
||||
// 설정 완료 표시
|
||||
await query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = CURRENT_TIMESTAMP WHERE key = $2',
|
||||
['true', 'setup_completed']
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '관리자 계정이 생성되었습니다',
|
||||
admin: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
created_at: admin.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin creation error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '관리자 계정 생성 중 오류가 발생했습니다',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 환경 설정 업데이트
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { jwt_secret, google_maps_api_key, email_config } = req.body;
|
||||
|
||||
const updates = [];
|
||||
|
||||
// JWT 시크릿 설정
|
||||
if (jwt_secret) {
|
||||
process.env.JWT_SECRET = jwt_secret;
|
||||
updates.push(['jwt_secret_set', 'true']);
|
||||
}
|
||||
|
||||
// Google Maps API 키 설정
|
||||
if (google_maps_api_key) {
|
||||
process.env.GOOGLE_MAPS_API_KEY = google_maps_api_key;
|
||||
updates.push(['google_maps_configured', 'true']);
|
||||
}
|
||||
|
||||
// 이메일 설정
|
||||
if (email_config) {
|
||||
// 이메일 설정 로직 (SMTP 등)
|
||||
updates.push(['email_configured', 'true']);
|
||||
}
|
||||
|
||||
// 설정 업데이트
|
||||
for (const [key, value] of updates) {
|
||||
await query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = CURRENT_TIMESTAMP WHERE key = $2',
|
||||
[value, key]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '환경 설정이 업데이트되었습니다',
|
||||
updated: updates.map(([key]) => key)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Config update error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '환경 설정 업데이트 중 오류가 발생했습니다',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터베이스 연결 테스트
|
||||
router.get('/test-db', async (req, res) => {
|
||||
try {
|
||||
const result = await query('SELECT NOW() as current_time, version() as db_version');
|
||||
res.json({
|
||||
success: true,
|
||||
message: '데이터베이스 연결 성공',
|
||||
data: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Database test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '데이터베이스 연결 실패',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
183
server/routes/travelPlans.js
Normal file
183
server/routes/travelPlans.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const express = require('express');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 여행 계획 전체 조회 (최신 1개)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// 최신 여행 계획 조회
|
||||
const planResult = await query(
|
||||
'SELECT * FROM travel_plans ORDER BY created_at DESC LIMIT 1'
|
||||
);
|
||||
|
||||
if (planResult.rows.length === 0) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// 해당 계획의 모든 일정 조회
|
||||
const schedules = await query(
|
||||
`SELECT ds.id, ds.schedule_date,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', a.id,
|
||||
'time', a.time,
|
||||
'title', a.title,
|
||||
'description', a.description,
|
||||
'location', a.location,
|
||||
'type', a.type,
|
||||
'coordinates', CASE
|
||||
WHEN a.lat IS NOT NULL AND a.lng IS NOT NULL
|
||||
THEN json_build_object('lat', a.lat, 'lng', a.lng)
|
||||
ELSE NULL
|
||||
END,
|
||||
'images', a.images,
|
||||
'links', a.links,
|
||||
'relatedPlaces', (
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', rp.id,
|
||||
'name', rp.name,
|
||||
'description', rp.description,
|
||||
'address', rp.address,
|
||||
'coordinates', CASE
|
||||
WHEN rp.lat IS NOT NULL AND rp.lng IS NOT NULL
|
||||
THEN json_build_object('lat', rp.lat, 'lng', rp.lng)
|
||||
ELSE NULL
|
||||
END,
|
||||
'memo', rp.memo,
|
||||
'willVisit', rp.will_visit,
|
||||
'category', rp.category,
|
||||
'images', rp.images,
|
||||
'links', rp.links
|
||||
)
|
||||
)
|
||||
FROM related_places rp
|
||||
WHERE rp.activity_id = a.id
|
||||
)
|
||||
) ORDER BY a.time
|
||||
) FILTER (WHERE a.id IS NOT NULL) as activities
|
||||
FROM day_schedules ds
|
||||
LEFT JOIN activities a ON a.day_schedule_id = ds.id
|
||||
WHERE ds.travel_plan_id = $1
|
||||
GROUP BY ds.id, ds.schedule_date
|
||||
ORDER BY ds.schedule_date`,
|
||||
[plan.id]
|
||||
);
|
||||
|
||||
const travelPlan = {
|
||||
id: plan.id,
|
||||
startDate: plan.start_date,
|
||||
endDate: plan.end_date,
|
||||
schedule: schedules.rows.map(row => ({
|
||||
date: row.schedule_date,
|
||||
activities: row.activities || []
|
||||
})),
|
||||
budget: {
|
||||
total: 0,
|
||||
accommodation: 0,
|
||||
food: 0,
|
||||
transportation: 0,
|
||||
shopping: 0,
|
||||
activities: 0
|
||||
},
|
||||
checklist: []
|
||||
};
|
||||
|
||||
res.json(travelPlan);
|
||||
} catch (error) {
|
||||
console.error('Error fetching travel plan:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 여행 계획 저장/업데이트
|
||||
router.post('/', async (req, res) => {
|
||||
const client = await query('BEGIN');
|
||||
|
||||
try {
|
||||
const { startDate, endDate, schedule } = req.body;
|
||||
|
||||
// 기존 계획 삭제 (단순화: 항상 최신 1개만 유지)
|
||||
await query('DELETE FROM travel_plans');
|
||||
|
||||
// 새 여행 계획 생성
|
||||
const planResult = await query(
|
||||
'INSERT INTO travel_plans (start_date, end_date) VALUES ($1, $2) RETURNING id',
|
||||
[startDate, endDate]
|
||||
);
|
||||
|
||||
const planId = planResult.rows[0].id;
|
||||
|
||||
// 일정별 데이터 삽입
|
||||
for (const day of schedule) {
|
||||
// day_schedule 삽입
|
||||
const scheduleResult = await query(
|
||||
'INSERT INTO day_schedules (travel_plan_id, schedule_date) VALUES ($1, $2) RETURNING id',
|
||||
[planId, day.date]
|
||||
);
|
||||
|
||||
const scheduleId = scheduleResult.rows[0].id;
|
||||
|
||||
// activities 삽입
|
||||
if (day.activities && day.activities.length > 0) {
|
||||
for (const activity of day.activities) {
|
||||
const activityResult = await query(
|
||||
`INSERT INTO activities (
|
||||
day_schedule_id, time, title, description, location, type, lat, lng, images, links
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[
|
||||
scheduleId,
|
||||
activity.time,
|
||||
activity.title,
|
||||
activity.description || null,
|
||||
activity.location || null,
|
||||
activity.type,
|
||||
activity.coordinates?.lat || null,
|
||||
activity.coordinates?.lng || null,
|
||||
activity.images || null,
|
||||
activity.links || null
|
||||
]
|
||||
);
|
||||
|
||||
const activityId = activityResult.rows[0].id;
|
||||
|
||||
// related_places 삽입
|
||||
if (activity.relatedPlaces && activity.relatedPlaces.length > 0) {
|
||||
for (const place of activity.relatedPlaces) {
|
||||
await query(
|
||||
`INSERT INTO related_places (
|
||||
activity_id, name, description, address, lat, lng, memo, will_visit, category, images, links
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
activityId,
|
||||
place.name,
|
||||
place.description || null,
|
||||
place.address || null,
|
||||
place.coordinates?.lat || null,
|
||||
place.coordinates?.lng || null,
|
||||
place.memo || null,
|
||||
place.willVisit || false,
|
||||
place.category || 'other',
|
||||
place.images || null,
|
||||
place.links || null
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await query('COMMIT');
|
||||
res.json({ success: true, id: planId });
|
||||
} catch (error) {
|
||||
await query('ROLLBACK');
|
||||
console.error('Error saving travel plan:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
88
server/routes/uploads.js
Normal file
88
server/routes/uploads.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// uploads 디렉토리 생성
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Multer 설정: 파일 저장 위치와 파일명 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 필터: 이미지만 허용
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('이미지 파일만 업로드 가능합니다 (jpeg, jpg, png, gif, webp)'));
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB 제한
|
||||
},
|
||||
fileFilter: fileFilter
|
||||
});
|
||||
|
||||
// 다중 이미지 업로드 (최대 5개)
|
||||
router.post('/', upload.array('images', 5), (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: '파일이 업로드되지 않았습니다' });
|
||||
}
|
||||
|
||||
// 업로드된 파일의 URL 배열 생성
|
||||
const fileUrls = req.files.map(file => `/uploads/${file.filename}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
files: fileUrls,
|
||||
message: `${req.files.length}개의 파일이 업로드되었습니다`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 단일 이미지 업로드
|
||||
router.post('/single', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: '파일이 업로드되지 않았습니다' });
|
||||
}
|
||||
|
||||
const fileUrl = `/uploads/${req.file.filename}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
file: fileUrl,
|
||||
message: '파일이 업로드되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
66
server/schema.sql
Normal file
66
server/schema.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- 여행 계획 테이블
|
||||
CREATE TABLE IF NOT EXISTS travel_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 날짜별 일정 테이블
|
||||
CREATE TABLE IF NOT EXISTS day_schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
travel_plan_id INTEGER REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
schedule_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 활동 테이블
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
day_schedule_id INTEGER REFERENCES day_schedules(id) ON DELETE CASCADE,
|
||||
time VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
location VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
images TEXT[],
|
||||
links TEXT[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 관련 장소 테이블
|
||||
CREATE TABLE IF NOT EXISTS related_places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
activity_id INTEGER REFERENCES activities(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
address VARCHAR(255),
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
memo TEXT,
|
||||
will_visit BOOLEAN DEFAULT false,
|
||||
category VARCHAR(50) DEFAULT 'other',
|
||||
images TEXT[],
|
||||
links TEXT[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 기본 포인트 테이블
|
||||
CREATE TABLE IF NOT EXISTS base_points (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
lat DECIMAL(10, 7) NOT NULL,
|
||||
lng DECIMAL(10, 7) NOT NULL,
|
||||
memo TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_day_schedules_plan ON day_schedules(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_schedule ON activities(day_schedule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_related_places_activity ON related_places(activity_id);
|
||||
226
server/schema_v2.sql
Normal file
226
server/schema_v2.sql
Normal file
@@ -0,0 +1,226 @@
|
||||
-- Travel Planner v2.0 Database Schema
|
||||
-- 멀티 사용자 및 여행 관리 시스템
|
||||
|
||||
-- 사용자 테이블
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
created_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 여행 계획 테이블 (확장)
|
||||
CREATE TABLE IF NOT EXISTS travel_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- 목적지 정보
|
||||
destination_country VARCHAR(100) NOT NULL,
|
||||
destination_city VARCHAR(100) NOT NULL,
|
||||
destination_region VARCHAR(100),
|
||||
destination_lat DECIMAL(10, 7),
|
||||
destination_lng DECIMAL(10, 7),
|
||||
|
||||
-- 날짜
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
|
||||
-- 메타데이터
|
||||
is_public BOOLEAN DEFAULT false,
|
||||
is_template BOOLEAN DEFAULT false,
|
||||
template_category VARCHAR(20) CHECK (template_category IN ('japan', 'korea', 'asia', 'europe', 'america', 'other')),
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
thumbnail VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'completed', 'cancelled')),
|
||||
|
||||
-- 타임스탬프
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 날짜별 일정 테이블
|
||||
CREATE TABLE IF NOT EXISTS day_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
travel_plan_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
schedule_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 활동 테이블 (확장)
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
day_schedule_id UUID NOT NULL REFERENCES day_schedules(id) ON DELETE CASCADE,
|
||||
time VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
location VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('attraction', 'food', 'accommodation', 'transport', 'other')),
|
||||
|
||||
-- 좌표
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
|
||||
-- 미디어
|
||||
images TEXT[] DEFAULT '{}',
|
||||
links JSONB DEFAULT '[]',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 관련 장소 테이블
|
||||
CREATE TABLE IF NOT EXISTS related_places (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
address VARCHAR(255),
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
memo TEXT,
|
||||
will_visit BOOLEAN DEFAULT false,
|
||||
category VARCHAR(50) DEFAULT 'other' CHECK (category IN ('restaurant', 'attraction', 'shopping', 'accommodation', 'other')),
|
||||
images TEXT[] DEFAULT '{}',
|
||||
links JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 기본 포인트 테이블
|
||||
CREATE TABLE IF NOT EXISTS base_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('accommodation', 'airport', 'station', 'parking', 'other')),
|
||||
lat DECIMAL(10, 7) NOT NULL,
|
||||
lng DECIMAL(10, 7) NOT NULL,
|
||||
memo TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 예산 테이블
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
travel_plan_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
total_amount DECIMAL(12, 2) DEFAULT 0,
|
||||
accommodation DECIMAL(12, 2) DEFAULT 0,
|
||||
food DECIMAL(12, 2) DEFAULT 0,
|
||||
transportation DECIMAL(12, 2) DEFAULT 0,
|
||||
shopping DECIMAL(12, 2) DEFAULT 0,
|
||||
activities DECIMAL(12, 2) DEFAULT 0,
|
||||
currency VARCHAR(3) DEFAULT 'KRW',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 체크리스트 테이블
|
||||
CREATE TABLE IF NOT EXISTS checklist_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
travel_plan_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
checked BOOLEAN DEFAULT false,
|
||||
category VARCHAR(50) DEFAULT 'other' CHECK (category IN ('preparation', 'shopping', 'visit', 'other')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 공유 링크 테이블
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trip_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
share_code VARCHAR(8) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
max_access_count INTEGER,
|
||||
|
||||
-- 권한
|
||||
can_view BOOLEAN DEFAULT true,
|
||||
can_edit BOOLEAN DEFAULT false,
|
||||
can_comment BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TIMESTAMP
|
||||
);
|
||||
|
||||
-- 댓글 테이블
|
||||
CREATE TABLE IF NOT EXISTS trip_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trip_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
is_edited BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 시스템 설정 테이블
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_user ON travel_plans(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_status ON travel_plans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_category ON travel_plans(template_category);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_public ON travel_plans(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_schedules_plan ON day_schedules(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_schedule ON activities(day_schedule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_related_places_activity ON related_places(activity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_base_points_user ON base_points(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_plan ON budgets(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_checklist_plan ON checklist_items(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_code ON share_links(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_trip ON share_links(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_trip ON trip_comments(trip_id);
|
||||
|
||||
-- 초기 시스템 설정 데이터
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('app_version', '2.0.0', '애플리케이션 버전'),
|
||||
('setup_completed', 'false', '초기 설정 완료 여부'),
|
||||
('jwt_secret_set', 'false', 'JWT 시크릿 설정 여부'),
|
||||
('google_maps_configured', 'false', 'Google Maps API 설정 여부'),
|
||||
('email_configured', 'false', '이메일 설정 여부')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- 업데이트 트리거 함수
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 업데이트 트리거 적용
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_travel_plans_updated_at ON travel_plans;
|
||||
CREATE TRIGGER update_travel_plans_updated_at BEFORE UPDATE ON travel_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_budgets_updated_at ON budgets;
|
||||
CREATE TRIGGER update_budgets_updated_at BEFORE UPDATE ON budgets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_checklist_updated_at ON checklist_items;
|
||||
CREATE TRIGGER update_checklist_updated_at BEFORE UPDATE ON checklist_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_comments_updated_at ON trip_comments;
|
||||
CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON trip_comments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_settings_updated_at ON system_settings;
|
||||
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON system_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
66
server/server.js
Normal file
66
server/server.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const { initializeDatabase } = require('./db');
|
||||
const { runMigrations } = require('./migrate');
|
||||
const travelPlanRoutes = require('./routes/travelPlans');
|
||||
const basePointsRoutes = require('./routes/basePoints');
|
||||
const uploadsRoutes = require('./routes/uploads');
|
||||
const { router: authRoutes } = require('./routes/auth');
|
||||
const setupRoutes = require('./routes/setup');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 미들웨어
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 정적 파일 제공 (업로드된 이미지)
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// 로그 미들웨어
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 라우트
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/travel-plans', travelPlanRoutes);
|
||||
app.use('/api/base-points', basePointsRoutes);
|
||||
app.use('/api/uploads', uploadsRoutes);
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 에러 핸들러
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
});
|
||||
|
||||
// 서버 시작
|
||||
async function startServer() {
|
||||
try {
|
||||
// 데이터베이스 초기화 (기존 스키마)
|
||||
await initializeDatabase();
|
||||
|
||||
// 마이그레이션 실행 (새로운 기능 추가)
|
||||
await runMigrations();
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📊 API available at http://localhost:${PORT}`);
|
||||
console.log(`🗄️ Database: Using existing kumamoto_map database`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
BIN
server/uploads/images-1762666367693-99912431.webp
Normal file
BIN
server/uploads/images-1762666367693-99912431.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
server/uploads/images-1762666492636-867051147.jpg
Normal file
BIN
server/uploads/images-1762666492636-867051147.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Reference in New Issue
Block a user