import Database from 'better-sqlite3'; import bcrypt from 'bcryptjs'; import fs from 'fs'; import path from 'path'; /** * SQLite DB 연결 및 스키마 초기화 * @param {string} dbPath * @returns {import('better-sqlite3').Database} */ export function initDb(dbPath) { const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT, avatar_url TEXT, role TEXT NOT NULL DEFAULT 'user', is_active INTEGER NOT NULL DEFAULT 1, last_login_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS decks ( id INTEGER PRIMARY KEY AUTOINCREMENT, share_code TEXT NOT NULL UNIQUE, share_type TEXT NOT NULL DEFAULT 'user', content_hash TEXT, user_id INTEGER, series TEXT NOT NULL, series_name TEXT, name TEXT NOT NULL DEFAULT '', cards_json TEXT NOT NULL, is_public INTEGER NOT NULL DEFAULT 1, view_count INTEGER NOT NULL DEFAULT 0, expires_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_decks_user_id ON decks(user_id); CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash); CREATE INDEX IF NOT EXISTS idx_decks_public_list ON decks(share_type, is_public, updated_at); CREATE TABLE IF NOT EXISTS deck_favorites ( user_id INTEGER NOT NULL, share_code TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, share_code), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (share_code) REFERENCES decks(share_code) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code); CREATE TABLE IF NOT EXISTS auth_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_type ON auth_tokens(user_id, type); CREATE TABLE IF NOT EXISTS email_send_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, month_key TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'reserved', created_at TEXT NOT NULL DEFAULT (datetime('now')), sent_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_email_send_logs_user_month ON email_send_logs(user_id, month_key, status); `); const deckCols = db.prepare('PRAGMA table_info(decks)').all(); if (!deckCols.some((c) => c.name === 'preview_image')) { db.exec('ALTER TABLE decks ADD COLUMN preview_image TEXT'); } const userCols = db.prepare('PRAGMA table_info(users)').all(); if (!userCols.some((c) => c.name === 'default_deck_public')) { db.exec('ALTER TABLE users ADD COLUMN default_deck_public INTEGER NOT NULL DEFAULT 1'); } if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) { db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0'); } if (!userCols.some((c) => c.name === 'last_login_at')) { db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT'); } if (!userCols.some((c) => c.name === 'email_verified_at')) { db.exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT'); db.exec(` UPDATE users SET email_verified_at = COALESCE(updated_at, created_at, datetime('now')) WHERE email_verified_at IS NULL `); } const usernameCaseConflicts = db.prepare(` SELECT lower(username) AS normalized_username, COUNT(*) AS count FROM users GROUP BY lower(username) HAVING COUNT(*) > 1 LIMIT 1 `).get(); if (!usernameCaseConflicts) { db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_nocase ON users(username COLLATE NOCASE) `); } else { console.warn( '대소문자만 다른 기존 사용자명이 있어 NOCASE 고유 인덱스를 만들지 못했습니다. 관리자 확인이 필요합니다.', ); } return db; } /** * 환경변수 기준 관리자 계정 시드 * ADMIN_EMAIL로 이미 가입한 경우 역할만 admin으로 승격한다. * @param {import('better-sqlite3').Database} db */ export function seedAdmin(db) { const email = process.env.ADMIN_EMAIL?.trim().toLowerCase(); const password = process.env.ADMIN_PASSWORD; const username = process.env.ADMIN_USERNAME || 'admin'; if (!email || !password) return; const existingAdmin = db.prepare('SELECT id FROM users WHERE role = ?').get('admin'); if (existingAdmin) return; const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email); if (existingUser) { db.prepare(` UPDATE users SET role = 'admin', email_verified_at = COALESCE(email_verified_at, datetime('now')), updated_at = datetime('now') WHERE id = ? `).run(existingUser.id); return; } const hash = bcrypt.hashSync(password, 12); db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, role, email_verified_at ) VALUES (?, ?, ?, ?, 'admin', datetime('now')) `).run(email, username, hash, '관리자'); }