diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx index f4bdc29..9dfec8c 100644 --- a/admin-panel/src/App.tsx +++ b/admin-panel/src/App.tsx @@ -64,10 +64,21 @@ interface Episode { feedCategory?: string; } +interface Setting { + key: string; + value: string | null; + isCredential: boolean; + description: string; + defaultValue: string; + required: boolean; + updatedAt: string; +} + function App() { const [feeds, setFeeds] = useState([]); const [stats, setStats] = useState(null); const [envVars, setEnvVars] = useState({}); + const [settings, setSettings] = useState([]); const [feedRequests, setFeedRequests] = useState([]); const [episodes, setEpisodes] = useState([]); const [loading, setLoading] = useState(true); @@ -80,8 +91,9 @@ function App() { const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>( {}, ); + const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({}); const [activeTab, setActiveTab] = useState< - "dashboard" | "feeds" | "episodes" | "env" | "batch" | "requests" + "dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests" >("dashboard"); useEffect(() => { @@ -91,11 +103,12 @@ function App() { const loadData = async () => { setLoading(true); try { - const [feedsRes, statsRes, envRes, requestsRes, episodesRes] = + const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes] = await Promise.all([ fetch("/api/admin/feeds"), fetch("/api/admin/stats"), fetch("/api/admin/env"), + fetch("/api/admin/settings"), fetch("/api/admin/feed-requests"), fetch("/api/admin/episodes"), ]); @@ -104,17 +117,19 @@ function App() { !feedsRes.ok || !statsRes.ok || !envRes.ok || + !settingsRes.ok || !requestsRes.ok || !episodesRes.ok ) { throw new Error("Failed to load data"); } - const [feedsData, statsData, envData, requestsData, episodesData] = + const [feedsData, statsData, envData, settingsData, requestsData, episodesData] = await Promise.all([ feedsRes.json(), statsRes.json(), envRes.json(), + settingsRes.json(), requestsRes.json(), episodesRes.json(), ]); @@ -122,6 +137,7 @@ function App() { setFeeds(feedsData); setStats(statsData); setEnvVars(envData); + setSettings(settingsData); setFeedRequests(requestsData); setEpisodes(episodesData); setError(null); @@ -338,6 +354,43 @@ function App() { setApprovalNotes({ ...approvalNotes, [requestId]: notes }); }; + const updateSetting = async (key: string, value: string) => { + try { + const res = await fetch(`/api/admin/settings/${key}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + + const data = await res.json(); + + if (res.ok) { + setSuccess(data.message); + setEditingSettings({ ...editingSettings, [key]: "" }); + loadData(); + } else { + setError(data.error || "設定更新に失敗しました"); + } + } catch (err) { + setError("設定更新に失敗しました"); + console.error("Error updating setting:", err); + } + }; + + const startEditingSetting = (key: string, currentValue: string | null) => { + setEditingSettings({ ...editingSettings, [key]: currentValue || "" }); + }; + + const cancelEditingSetting = (key: string) => { + const newEditing = { ...editingSettings }; + delete newEditing[key]; + setEditingSettings(newEditing); + }; + + const updateEditingValue = (key: string, value: string) => { + setEditingSettings({ ...editingSettings, [key]: value }); + }; + const deleteEpisode = async (episodeId: string, episodeTitle: string) => { if ( !confirm( @@ -424,6 +477,12 @@ function App() { > フィード承認 + + + + ) : ( +
+
+ {setting.value === null || setting.value === "" ? ( + 未設定 + ) : setting.isCredential ? ( + •••••••• + ) : ( + setting.value + )} +
+ +
+ )} + + + ))} + + +
+

設定について

+
    +
  • 必須設定: アプリケーションの動作に必要な設定です
  • +
  • 認証情報: セキュリティ上重要な情報で、表示時にマスクされます
  • +
  • 設定の変更は即座にアプリケーションに反映されます
  • +
  • デフォルト値が設定されている項目は、空にするとデフォルト値が使用されます
  • +
+
+ + )} + {activeTab === "env" && ( <>

環境変数設定

diff --git a/admin-server.ts b/admin-server.ts index 9e006ce..32239b1 100644 --- a/admin-server.ts +++ b/admin-server.ts @@ -14,12 +14,16 @@ import { fetchEpisodesWithArticles, getAllCategories, getAllFeedsIncludingInactive, + getAllSettings, getFeedByUrl, getFeedRequests, getFeedsByCategory, getFeedsGroupedByCategory, + getSetting, + getSettingsForAdminUI, toggleFeedActive, updateFeedRequestStatus, + updateSetting, } from "./services/database.js"; // Validate configuration on startup @@ -92,6 +96,74 @@ app.get("/api/admin/env", async (c) => { } }); +// Settings management API endpoints +app.get("/api/admin/settings", async (c) => { + try { + const settings = await getSettingsForAdminUI(); + return c.json(settings); + } catch (error) { + console.error("Error fetching settings:", error); + return c.json({ error: "Failed to fetch settings" }, 500); + } +}); + +app.get("/api/admin/settings/:key", async (c) => { + try { + const key = c.req.param("key"); + const setting = await getSetting(key); + + if (!setting) { + return c.json({ error: "Setting not found" }, 404); + } + + // Mask credential values for security + if (setting.isCredential && setting.value) { + setting.value = "••••••••"; + } + + return c.json(setting); + } catch (error) { + console.error("Error fetching setting:", error); + return c.json({ error: "Failed to fetch setting" }, 500); + } +}); + +app.put("/api/admin/settings/:key", async (c) => { + try { + const key = c.req.param("key"); + const { value } = await c.req.json<{ value: string }>(); + + if (!value && value !== "") { + return c.json({ error: "Setting value is required" }, 400); + } + + console.log(`🔧 Admin updating setting: ${key}`); + + // Check if setting exists + const existingSetting = await getSetting(key); + if (!existingSetting) { + return c.json({ error: "Setting not found" }, 404); + } + + const updated = await updateSetting(key, value); + + if (updated) { + return c.json({ + result: "UPDATED", + message: "Setting updated successfully", + key, + // Don't return the value for credentials + value: existingSetting.isCredential ? "••••••••" : value, + }); + } else { + return c.json({ error: "Failed to update setting" }, 500); + } + } catch (error) { + console.error("Error updating setting:", error); + return c.json({ error: "Failed to update setting" }, 500); + } +}); + // Feed management API endpoints app.get("/api/admin/feeds", async (c) => { try { diff --git a/schema.sql b/schema.sql index 856e584..daee222 100644 --- a/schema.sql +++ b/schema.sql @@ -68,6 +68,17 @@ CREATE TABLE IF NOT EXISTS feed_requests ( admin_notes TEXT ); +-- Environment variable settings stored in database +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + is_credential BOOLEAN DEFAULT 0, + description TEXT, + default_value TEXT, + required BOOLEAN DEFAULT 0, + updated_at TEXT NOT NULL +); + -- Create indexes for better performance CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id); CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date); @@ -78,3 +89,4 @@ CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status); CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at); CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status); CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at); +CREATE INDEX IF NOT EXISTS idx_settings_is_credential ON settings(is_credential); diff --git a/services/database.ts b/services/database.ts index 815c298..1e844ab 100644 --- a/services/database.ts +++ b/services/database.ts @@ -126,86 +126,21 @@ function initializeDatabase(): Database { db.exec("PRAGMA cache_size = 1000;"); db.exec("PRAGMA temp_store = memory;"); - // Ensure schema is set up - use the complete schema - db.exec(`CREATE TABLE IF NOT EXISTS feeds ( - id TEXT PRIMARY KEY, - url TEXT NOT NULL UNIQUE, - title TEXT, - description TEXT, - category TEXT, - last_updated TEXT, - created_at TEXT NOT NULL, - active BOOLEAN DEFAULT 1 - ); - - CREATE TABLE IF NOT EXISTS articles ( - id TEXT PRIMARY KEY, - feed_id TEXT NOT NULL, - title TEXT NOT NULL, - link TEXT NOT NULL UNIQUE, - description TEXT, - content TEXT, - pub_date TEXT NOT NULL, - discovered_at TEXT NOT NULL, - processed BOOLEAN DEFAULT 0, - FOREIGN KEY(feed_id) REFERENCES feeds(id) - ); - - CREATE TABLE IF NOT EXISTS episodes ( - id TEXT PRIMARY KEY, - article_id TEXT NOT NULL, - title TEXT NOT NULL, - description TEXT, - audio_path TEXT NOT NULL, - duration INTEGER, - file_size INTEGER, - category TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY(article_id) REFERENCES articles(id) - ); - - CREATE TABLE IF NOT EXISTS processed_feed_items ( - feed_url TEXT NOT NULL, - item_id TEXT NOT NULL, - processed_at TEXT NOT NULL, - PRIMARY KEY(feed_url, item_id) - ); - - CREATE TABLE IF NOT EXISTS tts_queue ( - id TEXT PRIMARY KEY, - item_id TEXT NOT NULL, - script_text TEXT NOT NULL, - retry_count INTEGER DEFAULT 0, - created_at TEXT NOT NULL, - last_attempted_at TEXT, - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed')) - ); - - CREATE TABLE IF NOT EXISTS feed_requests ( - id TEXT PRIMARY KEY, - url TEXT NOT NULL, - requested_by TEXT, - request_message TEXT, - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - created_at TEXT NOT NULL, - reviewed_at TEXT, - reviewed_by TEXT, - admin_notes TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id); - CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date); - CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed); - CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id); - CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active); - CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status); - CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at); - CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status); - CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at);`); + // Load and execute schema from file + const schemaPath = path.join(config.paths.projectRoot, "schema.sql"); + if (fs.existsSync(schemaPath)) { + const schema = fs.readFileSync(schemaPath, "utf-8"); + db.exec(schema); + } else { + throw new Error(`Schema file not found: ${schemaPath}`); + } // Perform database integrity checks and fixes performDatabaseIntegrityFixes(db); + // Initialize settings table with default values + initializeSettings(); + // ALTER // ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL; // Ensure the category column exists in feeds @@ -1666,6 +1601,267 @@ export async function getEpisodeCategoryMigrationStatus(): Promise<{ } } +// Settings management functions +export interface Setting { + key: string; + value: string | null; + isCredential: boolean; + description: string; + defaultValue: string; + required: boolean; + updatedAt: string; +} + +export async function initializeSettings(): Promise { + const defaultSettings: Omit[] = [ + { + key: "OPENAI_API_KEY", + value: null, + isCredential: true, + description: "OpenAI API Key for content generation", + defaultValue: "", + required: true, + }, + { + key: "OPENAI_API_ENDPOINT", + value: null, + isCredential: false, + description: "OpenAI API Endpoint URL", + defaultValue: "https://api.openai.com/v1", + required: false, + }, + { + key: "OPENAI_MODEL_NAME", + value: null, + isCredential: false, + description: "OpenAI Model Name", + defaultValue: "gpt-4o-mini", + required: false, + }, + { + key: "VOICEVOX_HOST", + value: null, + isCredential: false, + description: "VOICEVOX Server Host URL", + defaultValue: "http://localhost:50021", + required: false, + }, + { + key: "VOICEVOX_STYLE_ID", + value: null, + isCredential: false, + description: "VOICEVOX Voice Style ID", + defaultValue: "0", + required: false, + }, + { + key: "PODCAST_TITLE", + value: null, + isCredential: false, + description: "Podcast Title", + defaultValue: "自動生成ポッドキャスト", + required: false, + }, + { + key: "PODCAST_LINK", + value: null, + isCredential: false, + description: "Podcast Link URL", + defaultValue: "https://your-domain.com/podcast", + required: false, + }, + { + key: "PODCAST_DESCRIPTION", + value: null, + isCredential: false, + description: "Podcast Description", + defaultValue: "RSSフィードから自動生成された音声ポッドキャスト", + required: false, + }, + { + key: "PODCAST_LANGUAGE", + value: null, + isCredential: false, + description: "Podcast Language", + defaultValue: "ja", + required: false, + }, + { + key: "PODCAST_AUTHOR", + value: null, + isCredential: false, + description: "Podcast Author", + defaultValue: "管理者", + required: false, + }, + { + key: "PODCAST_CATEGORIES", + value: null, + isCredential: false, + description: "Podcast Categories", + defaultValue: "Technology", + required: false, + }, + { + key: "PODCAST_TTL", + value: null, + isCredential: false, + description: "Podcast TTL", + defaultValue: "60", + required: false, + }, + { + key: "PODCAST_BASE_URL", + value: null, + isCredential: false, + description: "Podcast Base URL", + defaultValue: "https://your-domain.com", + required: false, + }, + { + key: "ADMIN_PORT", + value: null, + isCredential: false, + description: "Admin Panel Port", + defaultValue: "3001", + required: false, + }, + { + key: "ADMIN_USERNAME", + value: null, + isCredential: true, + description: "Admin Panel Username", + defaultValue: "", + required: false, + }, + { + key: "ADMIN_PASSWORD", + value: null, + isCredential: true, + description: "Admin Panel Password", + defaultValue: "", + required: false, + }, + { + key: "DISABLE_INITIAL_BATCH", + value: null, + isCredential: false, + description: "Disable Initial Batch Process", + defaultValue: "false", + required: false, + }, + { + key: "FEED_URLS_FILE", + value: null, + isCredential: false, + description: "Feed URLs File Path", + defaultValue: "feed_urls.txt", + required: false, + }, + ]; + + const now = new Date().toISOString(); + + for (const setting of defaultSettings) { + try { + const stmt = db.prepare( + "INSERT OR IGNORE INTO settings (key, value, is_credential, description, default_value, required, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + stmt.run( + setting.key, + setting.value, + setting.isCredential ? 1 : 0, + setting.description, + setting.defaultValue, + setting.required ? 1 : 0, + now + ); + } catch (error) { + console.error(`Error initializing setting ${setting.key}:`, error); + } + } +} + +export async function getAllSettings(): Promise { + try { + const stmt = db.prepare("SELECT * FROM settings ORDER BY key"); + const rows = stmt.all() as any[]; + + return rows.map((row) => ({ + key: row.key, + value: row.value, + isCredential: Boolean(row.is_credential), + description: row.description, + defaultValue: row.default_value, + required: Boolean(row.required), + updatedAt: row.updated_at, + })); + } catch (error) { + console.error("Error getting all settings:", error); + throw error; + } +} + +export async function getSetting(key: string): Promise { + try { + const stmt = db.prepare("SELECT * FROM settings WHERE key = ?"); + const row = stmt.get(key) as any; + if (!row) return null; + + return { + key: row.key, + value: row.value, + isCredential: Boolean(row.is_credential), + description: row.description, + defaultValue: row.default_value, + required: Boolean(row.required), + updatedAt: row.updated_at, + }; + } catch (error) { + console.error(`Error getting setting ${key}:`, error); + throw error; + } +} + +export async function updateSetting(key: string, value: string): Promise { + try { + const now = new Date().toISOString(); + const stmt = db.prepare( + "UPDATE settings SET value = ?, updated_at = ? WHERE key = ?" + ); + const result = stmt.run(value, now, key); + return result.changes > 0; + } catch (error) { + console.error(`Error updating setting ${key}:`, error); + throw error; + } +} + +export async function deleteSetting(key: string): Promise { + try { + const stmt = db.prepare("DELETE FROM settings WHERE key = ?"); + const result = stmt.run(key); + return result.changes > 0; + } catch (error) { + console.error(`Error deleting setting ${key}:`, error); + throw error; + } +} + +export async function getSettingsForAdminUI(): Promise { + try { + const settings = await getAllSettings(); + return settings.map((setting) => ({ + ...setting, + // Mask credential values for security + value: setting.isCredential && setting.value ? "••••••••" : setting.value, + })); + } catch (error) { + console.error("Error getting settings for admin UI:", error); + throw error; + } +} + export function closeDatabase(): void { db.close(); }