🎯 프로젝트 리브랜딩: 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:
Hyungi Ahn
2025-11-25 10:39:58 +09:00
parent a01897f50f
commit fd5a68e44a
81 changed files with 18420 additions and 399 deletions

27
server/Dockerfile Normal file
View 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
View 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
View 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
View 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 };

View 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

File diff suppressed because it is too large Load Diff

22
server/package.json Normal file
View 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
View 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
};

View 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
View 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;

View 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
View 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
View 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
View 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
View 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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB