const express = require('express'); const cors = require('cors'); const Database = require('better-sqlite3'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const os = require('os'); const app = express(); const PORT = process.env.PORT || 3000; const MAX_PROFILES = 5; const DEFAULT_PROFILE_NAMES = { 1: 'Profil 1', 2: 'Profil 2', 3: 'Profil 3', 4: 'Profil 4', 5: 'Profil 5' }; const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope'; const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; const SEARCH_POST_HIDE_THRESHOLD = 2; const SEARCH_POST_RETENTION_DAYS = 90; const MAX_POST_TEXT_LENGTH = 4000; const MIN_TEXT_HASH_LENGTH = 120; const MAX_BOOKMARK_LABEL_LENGTH = 120; const MAX_BOOKMARK_QUERY_LENGTH = 200; const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160; const DAILY_BOOKMARK_URL_MAX_LENGTH = 800; const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800; const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120; const AUTOMATION_TYPE_REQUEST = 'request'; const AUTOMATION_TYPE_EMAIL = 'email'; const AUTOMATION_TYPE_FLOW = 'flow'; const AUTOMATION_MAX_NAME_LENGTH = 160; const AUTOMATION_MAX_URL_LENGTH = 2000; const AUTOMATION_MAX_BODY_LENGTH = 12000; const AUTOMATION_MAX_HEADERS_LENGTH = 6000; const AUTOMATION_MIN_INTERVAL_MINUTES = 5; const AUTOMATION_MAX_INTERVAL_MINUTES = 60 * 24 * 14; // 2 Wochen const AUTOMATION_DEFAULT_INTERVAL_MINUTES = 60; const AUTOMATION_MAX_JITTER_MINUTES = 120; const AUTOMATION_MAX_RESPONSE_PREVIEW = 4000; const AUTOMATION_WORKER_INTERVAL_MS = 30000; const AUTOMATION_MAX_STEPS = 3; const AUTOMATION_MAX_EMAIL_TO_LENGTH = 320; const AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH = 320; const AUTH_USERNAME = (process.env.AUTH_USERNAME || '').trim(); const AUTH_PASSWORD = (process.env.AUTH_PASSWORD || '').trim(); const AUTH_ENABLED = Boolean(AUTH_USERNAME && AUTH_PASSWORD); const AUTH_SESSION_COOKIE = 'fb_auth_token'; const AUTH_SESSION_MAX_AGE = 60 * 60 * 24 * 365 * 10; // ~10 Jahre "quasi dauerhaft" const SPORTS_SCORING_DEFAULTS = { enabled: 1, threshold: 5, auto_hide_enabled: 0, weights: { scoreline: 3, scoreEmoji: 2, sportEmoji: 2, sportVerb: 1.5, sportNoun: 2, hashtag: 1.5, teamToken: 2, competition: 2, celebration: 1, location: 1 } }; const SPORTS_SCORING_TERMS_DEFAULTS = { nouns: [ 'auswärtssieg', 'heimsieg', 'derbysieg', 'revanche', 'spiel', 'spieltag', 'match', 'derby', 'finale', 'cup', 'pokal', 'liga', 'bundesliga', 'oberliga', 'kreisliga', 'bezirksliga', 'meisterschaft', 'turnier', 'halbzeit', 'tabellenplatz', 'tabelle', 'tor', 'tore', 'treffer', 'stadion', 'arena', 'halle', 'trainerteam', 'mannschaft', 'fans', 'fanblock', 'jugend', 'u17', 'u19', 'u15' ], verbs: [ 'gewinnen', 'siegen', 'geholt', 'erkämpfen', 'erkämpft', 'erkämpfen', 'drehen', 'punkten', 'trifft', 'treffen', 'schießt', 'schiesst', 'schießen', 'schiessen', 'verteidigen', 'stürmen', 'kämpfen' ], competitions: [ 'bundesliga', 'liga', 'serie a', 'premier league', 'champions league', 'europa league', 'dfb-pokal', 'cup', 'pokal', 'qualifikation', 'qualirunde', 'halbfinale', 'viertelfinale', 'achtelfinale', 'relegation' ], celebrations: [ 'sieg', 'siege', 'auswärtssieg', 'heimsieg', 'auswärtsspiel', 'punkte', 'punkte geholt', 'man of the match', 'motm', 'tabellenführung', 'tabellenplatz', 'tabellendritter', 'tabellenzweiter' ], locations: [ 'auswärts', 'heimspiel', 'derby', 'arena', 'stadion', 'halle', 'bolle', 'bölle', 'mosel' ], negatives: [ 'rezept', 'kochen', 'politik', 'wahl', 'bundestag', 'landtag', 'software', 'release', 'update', 'konzert', 'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung' ] }; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { fs.mkdirSync(screenshotDir, { recursive: true }); } const automationConfigPath = path.join(__dirname, 'data', 'automation-config.json'); const defaultAutomationConfig = { smtp: { host: '', port: 587, secure: false, user: '', pass: '', from: '' } }; function ensureAutomationConfigFile() { try { if (!fs.existsSync(automationConfigPath)) { const dir = path.dirname(automationConfigPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(automationConfigPath, JSON.stringify(defaultAutomationConfig, null, 2), 'utf8'); console.log('Automation-Config angelegt unter', automationConfigPath); } } catch (error) { console.warn('Konnte Automation-Config nicht erstellen:', error.message); } } ensureAutomationConfigFile(); function loadAutomationConfig() { try { const raw = fs.readFileSync(automationConfigPath, 'utf8'); const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : { ...defaultAutomationConfig }; } catch (error) { return { ...defaultAutomationConfig }; } } const automationConfig = loadAutomationConfig(); let nodemailer = null; try { nodemailer = require('nodemailer'); } catch (error) { nodemailer = null; } // Middleware - Enhanced CORS for extension app.use(cors({ origin: (origin, callback) => { callback(null, origin || false); }, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true })); // Allow larger payloads because screenshots from high-res monitors can easily exceed 10 MB app.use(express.json({ limit: '30mb' })); // Additional CORS headers for extension compatibility app.use((req, res, next) => { const origin = req.headers.origin; const host = req.headers.host; const fallbackOrigin = host ? `${isSecureRequest(req) ? 'https' : 'http'}://${host}` : '*'; res.header('Access-Control-Allow-Origin', origin || fallbackOrigin); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.header('Access-Control-Allow-Credentials', 'true'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); // Simple session-based authentication (enabled when AUTH_USERNAME/PASSWORD are set) app.use(authGuard); // Assign per-browser profile scopes via cookies app.use(ensureProfileScope); // Database setup const dbPath = path.join(__dirname, 'data', 'tracker.db'); const db = new Database(dbPath); db.pragma('foreign_keys = ON'); const SSE_RETRY_INTERVAL_MS = 5000; const SSE_HEARTBEAT_INTERVAL_MS = 30000; const sseClients = new Map(); let nextSseClientId = 1; function scheduleAsync(fn) { if (typeof setImmediate === 'function') { setImmediate(fn); } else { setTimeout(fn, 0); } } function removeSseClient(clientId) { const client = sseClients.get(clientId); if (!client) { return; } sseClients.delete(clientId); if (client.heartbeat) { clearInterval(client.heartbeat); } } function addSseClient(res) { const clientId = nextSseClientId++; const client = { id: clientId, res, heartbeat: setInterval(() => { if (res.writableEnded) { removeSseClient(clientId); return; } try { res.write('event: heartbeat\ndata: {}\n\n'); } catch (error) { removeSseClient(clientId); } }, SSE_HEARTBEAT_INTERVAL_MS) }; sseClients.set(clientId, client); return client; } function broadcastSseEvent(payload) { if (!payload) { return; } let serialized; try { serialized = JSON.stringify(payload); } catch (error) { console.warn('Failed to serialize SSE payload:', error.message); return; } const message = `data: ${serialized}\n\n`; for (const [clientId, client] of sseClients.entries()) { const target = client && client.res; if (!target || target.writableEnded) { removeSseClient(clientId); continue; } try { target.write(message); } catch (error) { removeSseClient(clientId); } } } function queuePostBroadcast(postId, options = {}) { if (!postId) { return; } scheduleAsync(() => broadcastPostChangeById(postId, options)); } function ensureColumn(table, column, definition) { const info = db.prepare(`PRAGMA table_info(${table})`).all(); if (!info.some((row) => row.name === column)) { db.prepare(`ALTER TABLE ${table} ADD COLUMN ${definition}`).run(); } } ensureColumn('posts', 'post_text', 'post_text TEXT'); ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT'); ensureColumn('posts', 'content_key', 'content_key TEXT'); db.exec(` CREATE INDEX IF NOT EXISTS idx_posts_content_key ON posts(content_key) `); const updateContentKeyStmt = db.prepare('UPDATE posts SET content_key = ? WHERE id = ?'); const updatePostTextColumnsStmt = db.prepare('UPDATE posts SET post_text = ?, post_text_hash = ? WHERE id = ?'); const postsMissingKey = db.prepare(` SELECT id, url FROM posts WHERE content_key IS NULL OR content_key = '' `).all(); for (const entry of postsMissingKey) { const normalizedUrl = normalizeFacebookPostUrl(entry.url); const key = extractFacebookContentKey(normalizedUrl); if (key) { updateContentKeyStmt.run(key, entry.id); } } const postsPermalinks = db.prepare(` SELECT id, url, content_key FROM posts WHERE url LIKE '%/permalink.php%' `).all(); for (const entry of postsPermalinks) { const normalizedUrl = normalizeFacebookPostUrl(entry.url); const key = extractFacebookContentKey(normalizedUrl); if (key && key !== entry.content_key) { updateContentKeyStmt.run(key, entry.id); } } const postsMissingHash = db.prepare(` SELECT id, post_text FROM posts WHERE post_text IS NOT NULL AND TRIM(post_text) <> '' AND (post_text_hash IS NULL OR post_text_hash = '') `).all(); for (const entry of postsMissingHash) { const normalizedText = normalizePostText(entry.post_text); const hash = computePostTextHash(normalizedText); updatePostTextColumnsStmt.run(normalizedText, hash, entry.id); } function parseCookies(header) { if (!header || typeof header !== 'string') { return {}; } return header.split(';').reduce((acc, part) => { const index = part.indexOf('='); if (index === -1) { const key = part.trim(); if (key) { acc[key] = ''; } return acc; } const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key) { acc[key] = decodeURIComponent(value); } return acc; }, {}); } function isSecureRequest(req) { if (req.secure) { return true; } const forwardedProto = req.headers['x-forwarded-proto']; if (typeof forwardedProto === 'string') { return forwardedProto.split(',').map(value => value.trim().toLowerCase()).includes('https'); } return false; } const authSessions = new Map(); function buildAuthCookieValue(token, req) { const secure = isSecureRequest(req); const attributes = [ `${AUTH_SESSION_COOKIE}=${encodeURIComponent(token)}`, 'Path=/', `Max-Age=${AUTH_SESSION_MAX_AGE}`, 'HttpOnly' ]; if (secure) { attributes.push('Secure', 'SameSite=None'); } else { attributes.push('SameSite=Lax'); } return attributes.join('; '); } function clearAuthCookie(res, req) { const secure = isSecureRequest(req); const attributes = [ `${AUTH_SESSION_COOKIE}=`, 'Path=/', 'Max-Age=0', 'HttpOnly' ]; if (secure) { attributes.push('Secure', 'SameSite=None'); } else { attributes.push('SameSite=Lax'); } const existing = res.getHeader('Set-Cookie'); const value = attributes.join('; '); if (!existing) { res.setHeader('Set-Cookie', value); } else if (Array.isArray(existing)) { res.setHeader('Set-Cookie', [...existing, value]); } else { res.setHeader('Set-Cookie', [existing, value]); } } function createSession(username) { const token = crypto.randomBytes(32).toString('hex'); const expiresAt = Date.now() + AUTH_SESSION_MAX_AGE * 1000; authSessions.set(token, { username, expiresAt }); return { token, expiresAt }; } function getSessionFromRequest(req) { const cookies = parseCookies(req.headers.cookie); const token = cookies[AUTH_SESSION_COOKIE]; if (!token) { return null; } const session = authSessions.get(token); if (!session) { return null; } if (session.expiresAt <= Date.now()) { authSessions.delete(token); return null; } // Sliding expiration session.expiresAt = Date.now() + AUTH_SESSION_MAX_AGE * 1000; authSessions.set(token, session); return { token, ...session }; } function authGuard(req, res, next) { if (!AUTH_ENABLED || req.method === 'OPTIONS') { next(); return; } const publicPaths = ['/api/login', '/api/session', '/health']; if (publicPaths.includes(req.path)) { next(); return; } const session = getSessionFromRequest(req); if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; } req.authUser = session.username; next(); } function buildScopeCookieValue(scopeId, req) { const secure = isSecureRequest(req); const attributes = [ `${PROFILE_SCOPE_COOKIE}=${encodeURIComponent(scopeId)}`, 'Path=/', `Max-Age=${PROFILE_SCOPE_MAX_AGE}` ]; if (secure) { attributes.push('Secure', 'SameSite=None'); } else { attributes.push('SameSite=Lax'); } return attributes.join('; '); } function appendScopeCookie(res, value) { const existing = res.getHeader('Set-Cookie'); if (!existing) { res.setHeader('Set-Cookie', value); } else if (Array.isArray(existing)) { res.setHeader('Set-Cookie', [...existing, value]); } else { res.setHeader('Set-Cookie', [existing, value]); } } function ensureProfileScope(req, res, next) { const cookies = parseCookies(req.headers.cookie); let scopeId = cookies[PROFILE_SCOPE_COOKIE]; if (!scopeId) { scopeId = uuidv4(); } appendScopeCookie(res, buildScopeCookieValue(scopeId, req)); req.profileScope = scopeId; next(); } function appendCookieHeader(res, value) { const existing = res.getHeader('Set-Cookie'); if (!existing) { res.setHeader('Set-Cookie', value); } else if (Array.isArray(existing)) { res.setHeader('Set-Cookie', [...existing, value]); } else { res.setHeader('Set-Cookie', [existing, value]); } } app.post('/api/login', (req, res) => { try { if (!AUTH_ENABLED) { return res.status(400).json({ error: 'Authentication is not configured' }); } const { username, password } = req.body || {}; if (username !== AUTH_USERNAME || password !== AUTH_PASSWORD) { clearAuthCookie(res, req); return res.status(401).json({ error: 'Ungültige Zugangsdaten' }); } const session = createSession(username); appendCookieHeader(res, buildAuthCookieValue(session.token, req)); res.json({ authenticated: true, username }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/logout', (req, res) => { try { const session = getSessionFromRequest(req); if (session) { authSessions.delete(session.token); } clearAuthCookie(res, req); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/session', (req, res) => { try { if (!AUTH_ENABLED) { return res.json({ authenticated: true, auth_required: false }); } const session = getSessionFromRequest(req); if (!session) { return res.status(401).json({ authenticated: false, auth_required: true }); } res.json({ authenticated: true, username: session.username, auth_required: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); function getScopedProfileNumber(scopeId) { if (!scopeId) { return null; } const row = db.prepare('SELECT profile_number FROM profile_state_scoped WHERE scope_id = ?').get(scopeId); if (!row) { return null; } return sanitizeProfileNumber(row.profile_number); } function setScopedProfileNumber(scopeId, profileNumber) { if (!scopeId || !profileNumber) { return; } db.prepare(` INSERT INTO profile_state_scoped (scope_id, profile_number) VALUES (?, ?) ON CONFLICT(scope_id) DO UPDATE SET profile_number = excluded.profile_number `).run(scopeId, profileNumber); } function clampTargetCount(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed)) { return 1; } return Math.min(MAX_PROFILES, Math.max(1, parsed)); } function validateTargetCount(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { return null; } return parsed; } function sanitizeProfileNumber(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { return null; } return parsed; } function normalizeDeadline(value) { if (!value && value !== 0) { return null; } if (value instanceof Date) { if (!Number.isNaN(value.getTime())) { return value.toISOString(); } return null; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) { return null; } const parsed = new Date(trimmed); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } return null; } if (typeof value === 'number') { const parsed = new Date(value); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } return null; } return null; } function getProfileName(profileNumber) { return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; } function normalizeCreatorName(value) { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } return trimmed.slice(0, 160); } function normalizePostText(value) { if (typeof value !== 'string') { return null; } let text = value.replace(/\s+/g, ' ').trim(); if (!text) { return null; } if (text.length > MAX_POST_TEXT_LENGTH) { text = text.slice(0, MAX_POST_TEXT_LENGTH); } return text; } function computePostTextHash(text) { if (!text) { return null; } return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); } function normalizeBookmarkQuery(value) { if (typeof value !== 'string') { return null; } let query = value.trim(); if (!query) { return null; } query = query.replace(/\s+/g, ' '); if (query.length > MAX_BOOKMARK_QUERY_LENGTH) { query = query.slice(0, MAX_BOOKMARK_QUERY_LENGTH); } return query; } function normalizeBookmarkLabel(value, fallback = '') { const base = typeof value === 'string' ? value.trim() : ''; let label = base || fallback || ''; label = label.replace(/\s+/g, ' '); if (label.length > MAX_BOOKMARK_LABEL_LENGTH) { label = label.slice(0, MAX_BOOKMARK_LABEL_LENGTH); } return label; } function serializeBookmark(row) { if (!row) { return null; } return { id: row.id, label: row.label, query: row.query, created_at: sqliteTimestampToUTC(row.created_at), updated_at: sqliteTimestampToUTC(row.updated_at), last_clicked_at: sqliteTimestampToUTC(row.last_clicked_at) }; } function formatDayKeyFromDate(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function normalizeDayKeyValue(rawValue) { if (!rawValue && rawValue !== 0) { return null; } if (rawValue instanceof Date) { if (!Number.isNaN(rawValue.getTime())) { return formatDayKeyFromDate(rawValue); } return null; } if (typeof rawValue !== 'string' && typeof rawValue !== 'number') { return null; } const stringValue = String(rawValue).trim(); if (!stringValue) { return null; } const match = stringValue.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (match) { const year = parseInt(match[1], 10); const month = parseInt(match[2], 10); const day = parseInt(match[3], 10); const parsed = new Date(year, month - 1, day); if ( !Number.isNaN(parsed.getTime()) && parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day ) { return formatDayKeyFromDate(parsed); } } const parsed = new Date(stringValue); if (!Number.isNaN(parsed.getTime())) { return formatDayKeyFromDate(parsed); } return null; } function resolveDayKey(dayKey, { defaultToToday = true } = {}) { const normalized = normalizeDayKeyValue(dayKey); if (normalized) { return normalized; } return defaultToToday ? formatDayKeyFromDate() : null; } function dayKeyToDate(dayKey) { const normalized = resolveDayKey(dayKey); const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { return new Date(); } const year = parseInt(match[1], 10); const month = parseInt(match[2], 10); const day = parseInt(match[3], 10); return new Date(year, month - 1, day); } function addDays(date, offsetDays = 0) { const base = date instanceof Date ? date : new Date(); const result = new Date(base); result.setDate(result.getDate() + offsetDays); return result; } const DAILY_PLACEHOLDER_PATTERN = /\{\{\s*([^}]+)\s*\}\}/gi; function resolveDynamicUrlTemplate(template, dayKey) { if (typeof template !== 'string') { return ''; } const baseDayKey = resolveDayKey(dayKey); const baseDate = dayKeyToDate(baseDayKey); return template.replace(DAILY_PLACEHOLDER_PATTERN, (_match, contentRaw) => { const content = (contentRaw || '').trim(); if (!content) { return ''; } const counterMatch = content.match(/^counter:\s*([+-]?\d+)([+-]\d+)?$/i); if (counterMatch) { const base = parseInt(counterMatch[1], 10); const offset = counterMatch[2] ? parseInt(counterMatch[2], 10) || 0 : 0; if (Number.isNaN(base)) { return ''; } const date = addDays(baseDate, offset); return String(base + date.getDate()); } const placeholderMatch = content.match(/^(date|day|dd|mm|month|yyyy|yy)([+-]\d+)?$/i); if (!placeholderMatch) { return content; } const token = String(placeholderMatch[1] || '').toLowerCase(); const offset = placeholderMatch[2] ? parseInt(placeholderMatch[2], 10) || 0 : 0; const date = addDays(baseDate, offset); switch (token) { case 'date': return formatDayKeyFromDate(date); case 'day': return String(date.getDate()); case 'dd': return String(date.getDate()).padStart(2, '0'); case 'month': case 'mm': return String(date.getMonth() + 1).padStart(2, '0'); case 'yyyy': return String(date.getFullYear()); case 'yy': return String(date.getFullYear()).slice(-2); default: return token; } }); } function normalizeDailyBookmarkTitle(value, fallback = '') { const source = typeof value === 'string' ? value : fallback; let title = (source || '').trim(); if (!title && fallback) { title = String(fallback || '').trim(); } if (!title) { title = 'Bookmark'; } title = title.replace(/\s+/g, ' '); if (title.length > DAILY_BOOKMARK_TITLE_MAX_LENGTH) { title = title.slice(0, DAILY_BOOKMARK_TITLE_MAX_LENGTH); } return title; } function normalizeDailyBookmarkUrlTemplate(value) { if (typeof value !== 'string') { return null; } let template = value.trim(); if (!template) { return null; } if (template.length > DAILY_BOOKMARK_URL_MAX_LENGTH) { template = template.slice(0, DAILY_BOOKMARK_URL_MAX_LENGTH); } return template; } function normalizeDailyBookmarkNotes(value) { if (typeof value !== 'string') { return ''; } let notes = value.trim(); if (notes.length > DAILY_BOOKMARK_NOTES_MAX_LENGTH) { notes = notes.slice(0, DAILY_BOOKMARK_NOTES_MAX_LENGTH); } return notes; } function normalizeDailyBookmarkMarker(value) { if (typeof value !== 'string') { return ''; } let marker = value.trim(); marker = marker.replace(/\s+/g, ' '); if (marker.length > DAILY_BOOKMARK_MARKER_MAX_LENGTH) { marker = marker.slice(0, DAILY_BOOKMARK_MARKER_MAX_LENGTH); } return marker; } function normalizeDailyBookmarkActive(value) { if (value === undefined || value === null) { return 1; } if (typeof value === 'string') { const trimmed = value.trim().toLowerCase(); if (trimmed === 'false' || trimmed === '0' || trimmed === 'off') { return 0; } if (trimmed === 'true' || trimmed === '1' || trimmed === 'on') { return 1; } } return value ? 1 : 0; } function serializeDailyBookmark(row, dayKey) { if (!row) { return null; } const resolvedDayKey = resolveDayKey(dayKey); const resolvedUrl = resolveDynamicUrlTemplate(row.url_template, resolvedDayKey); return { id: row.id, title: row.title, url_template: row.url_template, marker: row.marker || '', is_active: Number(row.is_active ?? 1) !== 0, resolved_url: resolvedUrl, notes: row.notes || '', created_at: sqliteTimestampToUTC(row.created_at), updated_at: sqliteTimestampToUTC(row.updated_at), last_completed_at: sqliteTimestampToUTC(row.last_completed_at), completed_for_day: !!row.completed_for_day, day_key: resolvedDayKey }; } function normalizeFacebookPostUrl(rawValue) { if (typeof rawValue !== 'string') { return null; } let value = rawValue.trim(); if (!value) { return null; } const trackingIndex = value.indexOf('__cft__'); if (trackingIndex !== -1) { value = value.slice(0, trackingIndex); } value = value.replace(/[?&]$/, ''); let parsed; try { parsed = new URL(value); } catch (error) { try { parsed = new URL(value, 'https://www.facebook.com'); } catch (fallbackError) { return null; } } if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) { return null; } parsed.hostname = 'www.facebook.com'; parsed.protocol = 'https:'; parsed.port = ''; const normalizedPathBeforeTrim = parsed.pathname.replace(/\/+$/, '') || '/'; const lowerPathBeforeTrim = normalizedPathBeforeTrim.toLowerCase(); const watchId = parsed.searchParams.get('v') || parsed.searchParams.get('video_id'); if ((lowerPathBeforeTrim === '/watch' || lowerPathBeforeTrim === '/video.php') && watchId) { parsed.pathname = `/reel/${watchId}/`; parsed.search = ''; } else { const reelMatch = lowerPathBeforeTrim.match(/^\/reel\/([^/]+)$/); if (reelMatch) { parsed.pathname = `/reel/${reelMatch[1]}/`; parsed.search = ''; } } const cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit'; if ( FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id' || lowerKey === 'hoisted_section_header_type' || isSingleUnitParam ) { return; } cleanedParams.append(paramKey, paramValue); }); const multiPermalinkId = cleanedParams.get('multi_permalinks'); if (multiPermalinkId) { cleanedParams.delete('multi_permalinks'); const groupMatch = parsed.pathname.match(/^\/groups\/([A-Za-z0-9\.\-_]+)/); if (groupMatch && /^[0-9]+$/.test(multiPermalinkId)) { parsed.pathname = `/groups/${groupMatch[1]}/posts/${multiPermalinkId}`; } else if (groupMatch) { parsed.pathname = `/groups/${groupMatch[1]}/permalink/${multiPermalinkId}`; } } const normalizedPath = parsed.pathname.replace(/\/+$/, '').toLowerCase(); if (normalizedPath.startsWith('/hashtag/') || normalizedPath.startsWith('/watch/hashtag/')) { return null; } const search = cleanedParams.toString(); const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`; return formatted.replace(/[?&]$/, ''); } function extractFacebookContentKey(normalizedUrl) { if (!normalizedUrl) { return null; } try { const parsed = new URL(normalizedUrl); const pathnameRaw = parsed.pathname || '/'; const pathname = pathnameRaw.replace(/\/+$/, '') || '/'; const lowerPath = pathname.toLowerCase(); const params = parsed.searchParams; const reelMatch = lowerPath.match(/^\/reel\/([^/]+)/); if (reelMatch) { return `reel:${reelMatch[1]}`; } const watchId = params.get('v') || params.get('video_id'); if ((lowerPath === '/watch' || lowerPath === '/watch/') && watchId) { return `video:${watchId}`; } if (lowerPath === '/video.php' && watchId) { return `video:${watchId}`; } const photoId = params.get('fbid'); if ((lowerPath === '/photo.php' || lowerPath === '/photo') && photoId) { return `photo:${photoId}`; } const storyFbid = params.get('story_fbid'); if (lowerPath === '/permalink.php' && storyFbid) { return `story:${storyFbid}`; } if (storyFbid) { const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || ''; return `story:${ownerId}:${storyFbid}`; } const groupPostMatch = lowerPath.match(/^\/groups\/([^/]+)\/posts\/([^/]+)/); if (groupPostMatch) { return `group-post:${groupPostMatch[1]}:${groupPostMatch[2]}`; } const groupPermalinkMatch = lowerPath.match(/^\/groups\/([^/]+)\/permalink\/([^/]+)/); if (groupPermalinkMatch) { return `group-post:${groupPermalinkMatch[1]}:${groupPermalinkMatch[2]}`; } const pagePostMatch = lowerPath.match(/^\/([^/]+)\/posts\/([^/]+)/); if (pagePostMatch) { return `profile-post:${pagePostMatch[1]}:${pagePostMatch[2]}`; } const pageVideoMatch = lowerPath.match(/^\/([^/]+)\/videos\/([^/]+)/); if (pageVideoMatch) { return `video:${pageVideoMatch[2]}`; } const pagePhotoMatch = lowerPath.match(/^\/([^/]+)\/photos\/[^/]+\/([^/]+)/); if (pagePhotoMatch) { return `photo:${pagePhotoMatch[2]}`; } if (lowerPath === '/' && storyFbid) { const ownerId = params.get('id') || ''; return `story:${ownerId}:${storyFbid}`; } if (lowerPath === '/story.php' && storyFbid) { const ownerId = params.get('id') || ''; return `story:${ownerId}:${storyFbid}`; } const sortedParams = Array.from(params.entries()) .map(([key, value]) => `${key}=${value}`) .sort() .join('&'); return `generic:${lowerPath}?${sortedParams}`; } catch (error) { return `generic:${normalizedUrl}`; } } function getRequiredProfiles(targetCount) { const count = clampTargetCount(targetCount); return Array.from({ length: count }, (_, index) => index + 1); } function buildProfileStatuses(requiredProfiles, checks) { const validChecks = checks .map(check => { const profileNumber = sanitizeProfileNumber(check.profile_number); if (!profileNumber) { return null; } return { ...check, profile_number: profileNumber, profile_name: getProfileName(profileNumber) }; }) .filter(Boolean); const completedSet = new Set(validChecks.map(check => check.profile_number)); const checkByProfile = new Map(validChecks.map(check => [check.profile_number, check])); const statuses = requiredProfiles.map((profileNumber, index) => { const prerequisites = requiredProfiles.slice(0, index); const prerequisitesMet = prerequisites.every(num => completedSet.has(num)); const isChecked = completedSet.has(profileNumber); return { profile_number: profileNumber, profile_name: getProfileName(profileNumber), status: isChecked ? 'done' : (prerequisitesMet ? 'available' : 'locked'), checked_at: isChecked && checkByProfile.get(profileNumber) ? checkByProfile.get(profileNumber).checked_at : null }; }); return { statuses, completedChecks: validChecks, completedSet }; } function recalcCheckedCount(postId) { const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId); if (!post) { return null; } const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses } = buildProfileStatuses(requiredProfiles, checks); const checkedCount = statuses.filter(status => status.status === 'done').length; const updates = []; const params = []; if (post.checked_count !== checkedCount) { updates.push('checked_count = ?'); params.push(checkedCount); } if (post.target_count !== requiredProfiles.length) { updates.push('target_count = ?'); params.push(requiredProfiles.length); } if (updates.length) { updates.push('last_change = CURRENT_TIMESTAMP'); params.push(postId); db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params); } return checkedCount; } // Initialize database tables db.exec(` CREATE TABLE IF NOT EXISTS posts ( id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT, target_count INTEGER NOT NULL, checked_count INTEGER DEFAULT 0, screenshot_path TEXT, created_by_profile INTEGER, created_by_name TEXT, deadline_at DATETIME, post_text TEXT, post_text_hash TEXT, content_key TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_change DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS post_urls ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, url TEXT NOT NULL UNIQUE, is_primary INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_post_urls_post_id ON post_urls(post_id); `); db.exec(` DROP INDEX IF EXISTS idx_post_urls_primary; `); db.exec(` CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, profile_number INTEGER, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id) ); `); db.exec(` CREATE TABLE IF NOT EXISTS profile_state ( id INTEGER PRIMARY KEY CHECK (id = 1), profile_number INTEGER NOT NULL ); `); db.exec(` CREATE TABLE IF NOT EXISTS profile_state_scoped ( scope_id TEXT PRIMARY KEY, profile_number INTEGER NOT NULL ); `); db.exec(` CREATE TABLE IF NOT EXISTS ai_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), active_credential_id INTEGER, prompt_prefix TEXT, enabled INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS ai_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS profile_friends ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_number INTEGER NOT NULL, friend_names TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(profile_number) ); `); db.exec(` CREATE TABLE IF NOT EXISTS search_seen_posts ( url TEXT PRIMARY KEY, seen_count INTEGER NOT NULL DEFAULT 1, manually_hidden INTEGER NOT NULL DEFAULT 0, first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS maintenance_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), search_retention_days INTEGER DEFAULT ${SEARCH_POST_RETENTION_DAYS}, auto_purge_hidden INTEGER DEFAULT 1, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS moderation_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), sports_scoring_enabled INTEGER DEFAULT ${SPORTS_SCORING_DEFAULTS.enabled}, sports_score_threshold REAL DEFAULT ${SPORTS_SCORING_DEFAULTS.threshold}, sports_auto_hide_enabled INTEGER DEFAULT ${SPORTS_SCORING_DEFAULTS.auto_hide_enabled}, sports_score_weights TEXT, sports_terms TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); ensureColumn('moderation_settings', 'sports_terms', 'sports_terms TEXT'); ensureColumn('moderation_settings', 'sports_auto_hide_enabled', 'sports_auto_hide_enabled INTEGER DEFAULT 0'); db.exec(` CREATE INDEX IF NOT EXISTS idx_search_seen_posts_last_seen_at ON search_seen_posts(last_seen_at); `); db.exec(` CREATE TABLE IF NOT EXISTS bookmarks ( id TEXT PRIMARY KEY, label TEXT, query TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_clicked_at DATETIME, UNIQUE(query COLLATE NOCASE) ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_bookmarks_last_clicked_at ON bookmarks(last_clicked_at); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at); `); const listBookmarksStmt = db.prepare(` SELECT id, label, query, created_at, updated_at, last_clicked_at FROM bookmarks ORDER BY (last_clicked_at IS NULL), datetime(COALESCE(last_clicked_at, created_at)) DESC, label COLLATE NOCASE `); const getBookmarkByIdStmt = db.prepare(` SELECT id, label, query, created_at, updated_at, last_clicked_at FROM bookmarks WHERE id = ? `); const findBookmarkByQueryStmt = db.prepare(` SELECT id FROM bookmarks WHERE LOWER(query) = LOWER(?) `); const insertBookmarkStmt = db.prepare(` INSERT INTO bookmarks (id, label, query) VALUES (?, ?, ?) `); const deleteBookmarkStmt = db.prepare(` DELETE FROM bookmarks WHERE id = ? `); const updateBookmarkLastClickedStmt = db.prepare(` UPDATE bookmarks SET last_clicked_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? `); db.exec(` CREATE TABLE IF NOT EXISTS daily_bookmarks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, url_template TEXT NOT NULL, notes TEXT, is_active INTEGER NOT NULL DEFAULT 1, marker TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS daily_bookmark_checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, bookmark_id TEXT NOT NULL, day_key TEXT NOT NULL, completed_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bookmark_id) REFERENCES daily_bookmarks(id) ON DELETE CASCADE, UNIQUE(bookmark_id, day_key) ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_daily_bookmark_checks_day ON daily_bookmark_checks(day_key); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_daily_bookmarks_updated ON daily_bookmarks(updated_at); `); ensureColumn('daily_bookmarks', 'marker', 'marker TEXT DEFAULT \'\''); ensureColumn('daily_bookmarks', 'is_active', 'is_active INTEGER NOT NULL DEFAULT 1'); const listDailyBookmarksStmt = db.prepare(` SELECT b.id, b.title, b.url_template, b.notes, b.is_active, b.marker, b.created_at, b.updated_at, ( SELECT MAX(completed_at) FROM daily_bookmark_checks c WHERE c.bookmark_id = b.id ) AS last_completed_at, EXISTS( SELECT 1 FROM daily_bookmark_checks c WHERE c.bookmark_id = b.id AND c.day_key = @dayKey ) AS completed_for_day FROM daily_bookmarks b ORDER BY datetime(b.updated_at) DESC, datetime(b.created_at) DESC, b.title COLLATE NOCASE `); const getDailyBookmarkStmt = db.prepare(` SELECT b.id, b.title, b.url_template, b.notes, b.is_active, b.marker, b.created_at, b.updated_at, ( SELECT MAX(completed_at) FROM daily_bookmark_checks c WHERE c.bookmark_id = b.id ) AS last_completed_at, EXISTS( SELECT 1 FROM daily_bookmark_checks c WHERE c.bookmark_id = b.id AND c.day_key = @dayKey ) AS completed_for_day FROM daily_bookmarks b WHERE b.id = @bookmarkId `); const insertDailyBookmarkStmt = db.prepare(` INSERT INTO daily_bookmarks (id, title, url_template, notes, marker, is_active) VALUES (@id, @title, @url_template, @notes, @marker, @is_active) `); const findDailyBookmarkByUrlStmt = db.prepare(` SELECT id FROM daily_bookmarks WHERE LOWER(url_template) = LOWER(?) LIMIT 1 `); const findOtherDailyBookmarkByUrlStmt = db.prepare(` SELECT id FROM daily_bookmarks WHERE LOWER(url_template) = LOWER(@url) AND id <> @id LIMIT 1 `); const updateDailyBookmarkStmt = db.prepare(` UPDATE daily_bookmarks SET title = @title, url_template = @url_template, notes = @notes, marker = @marker, is_active = @is_active, updated_at = CURRENT_TIMESTAMP WHERE id = @id `); const deleteDailyBookmarkStmt = db.prepare(` DELETE FROM daily_bookmarks WHERE id = ? `); const upsertDailyBookmarkCheckStmt = db.prepare(` INSERT INTO daily_bookmark_checks (bookmark_id, day_key) VALUES (@bookmarkId, @dayKey) ON CONFLICT(bookmark_id, day_key) DO NOTHING `); const deleteDailyBookmarkCheckStmt = db.prepare(` DELETE FROM daily_bookmark_checks WHERE bookmark_id = @bookmarkId AND day_key = @dayKey `); db.exec(` CREATE TABLE IF NOT EXISTS automation_requests ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, type TEXT NOT NULL DEFAULT 'request', method TEXT NOT NULL DEFAULT 'GET', url_template TEXT, headers_json TEXT, body_template TEXT, email_to TEXT, email_subject_template TEXT, email_body_template TEXT, steps_json TEXT, interval_minutes INTEGER NOT NULL DEFAULT ${AUTOMATION_DEFAULT_INTERVAL_MINUTES}, jitter_minutes INTEGER DEFAULT 0, start_at DATETIME, run_until DATETIME, active INTEGER NOT NULL DEFAULT 1, last_run_at DATETIME, last_status TEXT, last_status_code INTEGER, last_error TEXT, next_run_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); ensureColumn('automation_requests', 'type', 'type TEXT NOT NULL DEFAULT \'request\''); ensureColumn('automation_requests', 'email_to', 'email_to TEXT'); ensureColumn('automation_requests', 'email_subject_template', 'email_subject_template TEXT'); ensureColumn('automation_requests', 'email_body_template', 'email_body_template TEXT'); ensureColumn('automation_requests', 'steps_json', 'steps_json TEXT'); ensureColumn('automation_requests', 'exclusion_windows_json', 'exclusion_windows_json TEXT'); db.exec(` CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run ON automation_requests(next_run_at); `); db.exec(` CREATE TABLE IF NOT EXISTS automation_request_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, trigger TEXT DEFAULT 'schedule', started_at DATETIME DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME, status TEXT, status_code INTEGER, error TEXT, response_body TEXT, duration_ms INTEGER, FOREIGN KEY (request_id) REFERENCES automation_requests(id) ON DELETE CASCADE ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_automation_request_runs_request ON automation_request_runs(request_id, started_at DESC); `); const listAutomationRequestsStmt = db.prepare(` SELECT id, name, description, type, method, url_template, headers_json, body_template, email_to, email_subject_template, email_body_template, steps_json, interval_minutes, jitter_minutes, start_at, run_until, exclusion_windows_json, active, last_run_at, last_status, last_status_code, last_error, next_run_at, ( SELECT COUNT(1) FROM automation_request_runs r WHERE r.request_id = automation_requests.id ) AS runs_count, created_at, updated_at FROM automation_requests ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC, name COLLATE NOCASE `); const getAutomationRequestStmt = db.prepare(` SELECT id, name, description, type, method, url_template, headers_json, body_template, email_to, email_subject_template, email_body_template, steps_json, interval_minutes, jitter_minutes, start_at, run_until, exclusion_windows_json, active, last_run_at, last_status, last_status_code, last_error, next_run_at, created_at, updated_at FROM automation_requests WHERE id = ? `); const insertAutomationRequestStmt = db.prepare(` INSERT INTO automation_requests ( id, name, description, type, method, url_template, headers_json, body_template, email_to, email_subject_template, email_body_template, steps_json, interval_minutes, jitter_minutes, start_at, run_until, exclusion_windows_json, active, last_run_at, last_status, last_status_code, last_error, next_run_at ) VALUES ( @id, @name, @description, @type, @method, @url_template, @headers_json, @body_template, @email_to, @email_subject_template, @email_body_template, @steps_json, @interval_minutes, @jitter_minutes, @start_at, @run_until, @exclusion_windows_json, @active, @last_run_at, @last_status, @last_status_code, @last_error, @next_run_at ) `); const updateAutomationRequestStmt = db.prepare(` UPDATE automation_requests SET name = @name, description = @description, type = @type, method = @method, url_template = @url_template, headers_json = @headers_json, body_template = @body_template, email_to = @email_to, email_subject_template = @email_subject_template, email_body_template = @email_body_template, steps_json = @steps_json, interval_minutes = @interval_minutes, jitter_minutes = @jitter_minutes, start_at = @start_at, run_until = @run_until, exclusion_windows_json = @exclusion_windows_json, active = @active, last_run_at = @last_run_at, last_status = @last_status, last_status_code = @last_status_code, last_error = @last_error, next_run_at = @next_run_at, updated_at = CURRENT_TIMESTAMP WHERE id = @id `); const deleteAutomationRequestStmt = db.prepare('DELETE FROM automation_requests WHERE id = ?'); const listAutomationRunsStmt = db.prepare(` SELECT id, request_id, trigger, started_at, completed_at, status, status_code, error, response_body, duration_ms FROM automation_request_runs WHERE request_id = @requestId ORDER BY datetime(started_at) DESC LIMIT @limit `); const insertAutomationRunStmt = db.prepare(` INSERT INTO automation_request_runs ( request_id, trigger, started_at, completed_at, status, status_code, error, response_body, duration_ms ) VALUES ( @request_id, @trigger, @started_at, @completed_at, @status, @status_code, @error, @response_body, @duration_ms ) `); const listDueAutomationRequestsStmt = db.prepare(` SELECT * FROM automation_requests WHERE active = 1 AND next_run_at IS NOT NULL AND datetime(next_run_at) <= datetime(@now) ORDER BY datetime(next_run_at) ASC LIMIT 10 `); ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); ensureColumn('posts', 'deadline_at', 'deadline_at DATETIME'); ensureColumn('posts', 'created_by_name', 'created_by_name TEXT'); ensureColumn('posts', 'last_change', 'last_change DATETIME'); ensureColumn('posts', 'is_successful', 'is_successful INTEGER DEFAULT 0'); ensureColumn('ai_settings', 'active_credential_id', 'active_credential_id INTEGER'); ensureColumn('ai_settings', 'prompt_prefix', 'prompt_prefix TEXT'); ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); ensureColumn('ai_credentials', 'last_used_at', 'last_used_at DATETIME'); ensureColumn('ai_credentials', 'last_success_at', 'last_success_at DATETIME'); ensureColumn('ai_credentials', 'last_error_message', 'last_error_message TEXT'); ensureColumn('ai_credentials', 'last_error_at', 'last_error_at DATETIME'); ensureColumn('ai_credentials', 'last_status_code', 'last_status_code INTEGER'); ensureColumn('ai_credentials', 'last_rate_limit_remaining', 'last_rate_limit_remaining TEXT'); ensureColumn('ai_credentials', 'rate_limit_reset_at', 'rate_limit_reset_at DATETIME'); ensureColumn('ai_credentials', 'auto_disabled', 'auto_disabled INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'auto_disabled_reason', 'auto_disabled_reason TEXT'); ensureColumn('ai_credentials', 'auto_disabled_until', 'auto_disabled_until DATETIME'); ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); ensureColumn('search_seen_posts', 'sports_auto_hidden', 'sports_auto_hidden INTEGER NOT NULL DEFAULT 0'); db.exec(` CREATE TABLE IF NOT EXISTS ai_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id INTEGER NOT NULL, event_type TEXT NOT NULL, status_code INTEGER, message TEXT, metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (credential_id) REFERENCES ai_credentials(id) ON DELETE CASCADE ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_ai_usage_events_credential_created ON ai_usage_events(credential_id, created_at DESC); `); db.prepare(` UPDATE posts SET last_change = COALESCE( last_change, (SELECT MAX(checked_at) FROM checks WHERE checks.post_id = posts.id), created_at, CURRENT_TIMESTAMP ) WHERE last_change IS NULL `).run(); function touchPost(postId, reason = null) { if (!postId) { return; } try { db.prepare('UPDATE posts SET last_change = CURRENT_TIMESTAMP WHERE id = ?').run(postId); } catch (error) { console.warn(`Failed to update last_change for post ${postId}:`, error.message); } queuePostBroadcast(postId, { reason: reason || 'touch' }); } function normalizeExistingPostUrls() { const rows = db.prepare('SELECT id, url FROM posts').all(); let updatedCount = 0; for (const row of rows) { const cleaned = normalizeFacebookPostUrl(row.url); if (!cleaned || cleaned === row.url) { continue; } const conflict = db.prepare('SELECT id FROM posts WHERE url = ?').get(cleaned); if (conflict && conflict.id !== row.id) { console.warn(`Skipping URL normalization for post ${row.id} due to existing post ${conflict.id}`); continue; } try { db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(cleaned, row.id); const updatedKey = extractFacebookContentKey(cleaned); updateContentKeyStmt.run(updatedKey || null, row.id); updatedCount += 1; } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { console.warn(`Skipping URL normalization for post ${row.id} because the cleaned URL is already used.`); continue; } console.warn(`Failed to normalize URL for post ${row.id}:`, error.message); } } if (updatedCount) { console.log(`Normalized URLs for ${updatedCount} stored posts.`); } } normalizeExistingPostUrls(); function normalizeExistingPostUrlMappings() { const rows = db.prepare('SELECT id, url FROM post_urls').all(); let updated = 0; let removed = 0; for (const row of rows) { const normalized = normalizeFacebookPostUrl(row.url); if (!normalized) { continue; } if (normalized === row.url) { continue; } try { db.prepare('UPDATE post_urls SET url = ? WHERE id = ?').run(normalized, row.id); updated += 1; } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { db.prepare('DELETE FROM post_urls WHERE id = ?').run(row.id); removed += 1; } else { console.warn(`Failed to normalize post_urls entry ${row.id}:`, error.message); } } } if (updated || removed) { console.log(`Normalized post_urls entries: updated ${updated}, removed ${removed}`); } } normalizeExistingPostUrlMappings(); db.prepare('DELETE FROM post_urls WHERE url IN (SELECT url FROM posts)').run(); function truncateString(value, maxLength) { if (typeof value !== 'string') { return value; } if (!Number.isFinite(maxLength) || maxLength <= 0) { return value; } return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; } function ensureIsoDate(value) { if (!value) { return null; } if (value instanceof Date) { const time = value.getTime(); return Number.isNaN(time) ? null : value.toISOString(); } if (typeof value === 'number') { return ensureIsoDate(new Date(value)); } const date = new Date(value); const time = date.getTime(); return Number.isNaN(time) ? null : date.toISOString(); } function parseRetryAfter(value) { if (!value && value !== 0) { return null; } if (typeof value === 'number') { return value >= 0 ? Math.round(value) : null; } const numeric = Number(value); if (!Number.isNaN(numeric)) { return numeric >= 0 ? Math.round(numeric) : null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return null; } const diffSeconds = Math.round((date.getTime() - Date.now()) / 1000); return diffSeconds >= 0 ? diffSeconds : null; } function parseRateLimitReset(value) { if (!value && value !== 0) { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; } if (typeof value === 'number') { if (value > 1e12) { return new Date(value); } if (value > 1e9) { return new Date(value * 1000); } return new Date(Date.now() + value * 1000); } const numeric = Number(value); if (!Number.isNaN(numeric)) { if (numeric > 1e12) { return new Date(numeric); } if (numeric > 1e9) { return new Date(numeric * 1000); } return new Date(Date.now() + numeric * 1000); } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function extractRateLimitInfo(response, provider) { const info = { provider: provider || null }; if (!response || !response.headers || typeof response.headers.get !== 'function') { return info; } const headersOfInterest = {}; const captureKeys = new Set([ 'retry-after', 'x-ratelimit-remaining', 'x-ratelimit-remaining-requests', 'x-ratelimit-reset', 'x-ratelimit-reset-requests', 'x-ratelimit-limit', 'x-rate-limit-reset', 'x-rate-limit-remaining' ]); try { response.headers.forEach((value, key) => { const normalizedKey = key.toLowerCase(); if (captureKeys.has(normalizedKey)) { headersOfInterest[normalizedKey] = value; } }); } catch (error) { console.warn('Failed to iterate rate limit headers:', error.message); } if (Object.keys(headersOfInterest).length) { info.headers = headersOfInterest; } const retryAfterHeader = response.headers.get('retry-after'); const retryAfterSeconds = parseRetryAfter(retryAfterHeader); if (retryAfterSeconds !== null) { info.retryAfterSeconds = retryAfterSeconds; } const remainingHeader = response.headers.get('x-ratelimit-remaining-requests') || response.headers.get('x-ratelimit-remaining') || response.headers.get('x-rate-limit-remaining'); if (remainingHeader !== null && remainingHeader !== undefined) { info.rateLimitRemaining = remainingHeader; } const resetHeader = response.headers.get('x-ratelimit-reset-requests') || response.headers.get('x-ratelimit-reset') || response.headers.get('x-rate-limit-reset'); const resetDate = parseRateLimitReset(resetHeader); if (resetDate) { info.rateLimitResetAt = resetDate.toISOString(); } return info; } const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed']; function determineAutoDisable(error) { if (!error) { return null; } const status = error.status || error.statusCode || null; const baseMessage = typeof error.message === 'string' ? error.message : ''; const errorDetails = error.apiError && typeof error.apiError === 'object' ? (error.apiError.error?.message || error.apiError.error || error.apiError.message || '') : ''; const combinedMessage = `${baseMessage} ${errorDetails}`.toLowerCase(); let isRateLimit = status === 429; if (!isRateLimit && status === 403) { isRateLimit = RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)); } if (!isRateLimit && combinedMessage) { isRateLimit = RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)); } if (!isRateLimit) { return null; } let retryAfterSeconds = typeof error.retryAfterSeconds === 'number' ? error.retryAfterSeconds : null; if ((!retryAfterSeconds || retryAfterSeconds <= 0) && error.rateLimitResetAt) { const resetDate = new Date(error.rateLimitResetAt); if (!Number.isNaN(resetDate.getTime())) { retryAfterSeconds = Math.round((resetDate.getTime() - Date.now()) / 1000); } } if (!retryAfterSeconds || retryAfterSeconds < 0) { retryAfterSeconds = 900; // 15 minutes fallback } if (retryAfterSeconds < 10) { return null; } const untilDate = new Date(Date.now() + retryAfterSeconds * 1000); const reason = status ? `Rate limit erreicht (HTTP ${status})` : 'Rate limit erreicht'; return { reason, seconds: retryAfterSeconds, until: untilDate }; } function parseAutomationDate(input) { if (!input && input !== 0) { return null; } if (input === 'now') { return new Date().toISOString(); } if (input instanceof Date) { const time = input.getTime(); return Number.isNaN(time) ? null : input.toISOString(); } const date = new Date(input); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function clampAutomationIntervalMinutes(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return AUTOMATION_DEFAULT_INTERVAL_MINUTES; } return Math.max( AUTOMATION_MIN_INTERVAL_MINUTES, Math.min(AUTOMATION_MAX_INTERVAL_MINUTES, Math.round(numeric)) ); } function clampAutomationJitterMinutes(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric < 0) { return 0; } return Math.min(AUTOMATION_MAX_JITTER_MINUTES, Math.round(numeric)); } function toMinutesOfDay(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); const match = /^(\d{1,2}):(\d{2})$/.exec(trimmed); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; return hours * 60 + minutes; } function formatMinutesOfDay(totalMinutes) { if (typeof totalMinutes !== 'number' || Number.isNaN(totalMinutes)) return null; const minutes = Math.max(0, Math.min(1439, Math.floor(totalMinutes))); const h = String(Math.floor(minutes / 60)).padStart(2, '0'); const m = String(minutes % 60).padStart(2, '0'); return `${h}:${m}`; } function parseExclusionWindows(raw) { let source = raw; if (typeof source === 'string') { try { source = JSON.parse(source); } catch (error) { source = []; } } if (!Array.isArray(source)) { return []; } const windows = []; for (const item of source) { if (!item || typeof item !== 'object') continue; const startStr = typeof item.start === 'string' ? item.start : (typeof item.from === 'string' ? item.from : ''); const endStr = typeof item.end === 'string' ? item.end : (typeof item.to === 'string' ? item.to : ''); const startMinutes = toMinutesOfDay(startStr); const endMinutes = toMinutesOfDay(endStr); if (startMinutes === null || endMinutes === null) continue; if (startMinutes >= endMinutes) continue; windows.push({ start: formatMinutesOfDay(startMinutes), end: formatMinutesOfDay(endMinutes), startMinutes, endMinutes }); } windows.sort((a, b) => a.startMinutes - b.startMinutes); return windows; } function serializeExclusionWindows(raw, existingJson = null) { if (raw === undefined) { return existingJson || null; } const parsed = parseExclusionWindows(raw); if (!parsed.length) { return null; } try { return JSON.stringify(parsed.map((item) => ({ start: item.start, end: item.end }))); } catch (error) { return null; } } function normalizeAutomationHeaders(raw) { if (!raw) { return {}; } let source = raw; if (typeof raw === 'string') { const trimmed = raw.trim(); if (!trimmed) { return {}; } try { source = JSON.parse(trimmed); } catch (error) { source = trimmed.split('\n').reduce((acc, line) => { const idx = line.indexOf(':'); if (idx === -1) { return acc; } const key = line.slice(0, idx).trim(); const value = line.slice(idx + 1).trim(); if (key) { acc[key] = value; } return acc; }, {}); } } if (!source || typeof source !== 'object') { return {}; } const headers = {}; for (const [key, value] of Object.entries(source)) { if (!key) { continue; } const normalizedKey = String(key).trim(); if (!normalizedKey) { continue; } let normalizedValue; if (value === undefined || value === null) { normalizedValue = ''; } else if (typeof value === 'string') { normalizedValue = value.trim(); } else if (typeof value === 'number' || typeof value === 'boolean') { normalizedValue = String(value); } else { try { normalizedValue = JSON.stringify(value); } catch (error) { normalizedValue = ''; } } headers[normalizedKey] = truncateString(normalizedValue, 1000); } return headers; } function serializeAutomationHeaders(headers) { if (!headers || typeof headers !== 'object' || !Object.keys(headers).length) { return null; } try { const serialized = JSON.stringify(headers); return serialized.length > AUTOMATION_MAX_HEADERS_LENGTH ? null : serialized; } catch (error) { return null; } } function renderAutomationTemplate(template, context = {}) { if (typeof template !== 'string') { return ''; } const baseDate = context.now instanceof Date ? context.now : new Date(); return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_, keyRaw) => { const key = String(keyRaw || '').trim(); if (!key) { return ''; } if (key === 'uuid') { return uuidv4(); } const dateOffsetMatch = key.match(/^date([+-]\d+)?$/); if (dateOffsetMatch) { const offset = dateOffsetMatch[1] ? parseInt(dateOffsetMatch[1], 10) : 0; const shifted = new Date(baseDate); shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset)); return shifted.toISOString().slice(0, 10); } const dayOffsetMatch = key.match(/^day([+-]\d+)?$/); if (dayOffsetMatch) { const offset = dayOffsetMatch[1] ? parseInt(dayOffsetMatch[1], 10) : 0; const shifted = new Date(baseDate); shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset)); return String(shifted.getDate()).padStart(2, '0'); } switch (key) { case 'today': case 'date': return baseDate.toISOString().slice(0, 10); case 'iso': case 'now': case 'datetime': return baseDate.toISOString(); case 'timestamp': return String(baseDate.getTime()); case 'year': return String(baseDate.getFullYear()); case 'month': return String(baseDate.getMonth() + 1).padStart(2, '0'); case 'day': return String(baseDate.getDate()).padStart(2, '0'); case 'hour': return String(baseDate.getHours()).padStart(2, '0'); case 'minute': return String(baseDate.getMinutes()).padStart(2, '0'); case 'weekday': return baseDate.toLocaleDateString('de-DE', { weekday: 'long' }); case 'weekday_short': return baseDate.toLocaleDateString('de-DE', { weekday: 'short' }); default: return context[key] !== undefined && context[key] !== null ? String(context[key]) : ''; } }); } function buildAutomationTemplateContext(baseDate = new Date()) { const now = baseDate instanceof Date ? baseDate : new Date(); return { now, date: now.toISOString().slice(0, 10), today: now.toISOString().slice(0, 10), iso: now.toISOString(), datetime: now.toISOString(), timestamp: now.getTime(), year: now.getFullYear(), month: String(now.getMonth() + 1).padStart(2, '0'), day: String(now.getDate()).padStart(2, '0'), hour: String(now.getHours()).padStart(2, '0'), minute: String(now.getMinutes()).padStart(2, '0'), weekday: now.toLocaleDateString('de-DE', { weekday: 'long' }), weekday_short: now.toLocaleDateString('de-DE', { weekday: 'short' }) }; } function renderAutomationHeaders(headersJson, context) { if (!headersJson) { return {}; } let parsed = {}; try { parsed = JSON.parse(headersJson); } catch (error) { parsed = {}; } if (!parsed || typeof parsed !== 'object') { return {}; } const rendered = {}; for (const [key, value] of Object.entries(parsed)) { const renderedKey = renderAutomationTemplate(key, context).trim(); if (!renderedKey) { continue; } const renderedValue = renderAutomationTemplate( value === undefined || value === null ? '' : String(value), context ); rendered[renderedKey] = renderedValue; } return rendered; } function computeNextAutomationRun(request, options = {}) { if (!request) { return null; } const now = options.fromDate instanceof Date ? options.fromDate : new Date(); const intervalMinutes = clampAutomationIntervalMinutes( request.interval_minutes || AUTOMATION_DEFAULT_INTERVAL_MINUTES ); const jitterMinutes = clampAutomationJitterMinutes(request.jitter_minutes || 0); const lastRun = request.last_run_at ? new Date(request.last_run_at) : null; const startAt = request.start_at ? new Date(request.start_at) : null; const exclusionWindows = parseExclusionWindows(request.exclusion_windows_json || request.exclusion_windows || []); let base = lastRun ? new Date(lastRun.getTime() + intervalMinutes * 60000) : (startAt ? new Date(startAt) : new Date(now.getTime() + intervalMinutes * 60000)); if (base < now) { base = new Date(now.getTime() + intervalMinutes * 60000); } const jitterMs = jitterMinutes > 0 ? Math.floor(Math.random() * ((jitterMinutes * 60000) + 1)) : 0; let candidate = new Date(base.getTime() + jitterMs); const until = request.run_until ? new Date(request.run_until) : null; const MAX_SHIFT_ITERATIONS = 50; let shifts = 0; while (shifts < MAX_SHIFT_ITERATIONS) { const minutes = candidate.getHours() * 60 + candidate.getMinutes(); const hit = exclusionWindows.find((w) => minutes >= w.startMinutes && minutes < w.endMinutes); if (!hit) { break; } const nextAllowed = new Date(candidate); nextAllowed.setHours(0, 0, 0, 0); nextAllowed.setMinutes(hit.endMinutes); if (nextAllowed <= candidate) { nextAllowed.setDate(nextAllowed.getDate() + 1); } candidate = nextAllowed; shifts += 1; } if (shifts >= MAX_SHIFT_ITERATIONS) { return null; } if (until && !Number.isNaN(until.getTime()) && candidate > until) { return null; } return candidate.toISOString(); } function ensureNextRunForRequest(request, options = {}) { if (!request || !request.id || !request.active) { return null; } const existingNext = request.next_run_at; if (existingNext) { return existingNext; } const next = computeNextAutomationRun(request, options); if (!next) { return null; } try { updateAutomationRequestStmt.run({ ...request, next_run_at: next }); } catch (error) { console.warn(`Failed to persist next_run_at for ${request.id}:`, error.message); } return next; } function serializeAutomationRequest(row) { if (!row) { return null; } let headers = {}; if (row.headers_json) { try { const parsed = JSON.parse(row.headers_json); if (parsed && typeof parsed === 'object') { headers = parsed; } } catch (error) { headers = {}; } } return { id: row.id, name: row.name, type: row.type || AUTOMATION_TYPE_REQUEST, description: row.description || '', method: row.method || 'GET', url_template: row.url_template, headers, body_template: row.body_template || '', email_to: row.email_to || '', email_subject_template: row.email_subject_template || '', email_body_template: row.email_body_template || '', steps: row.steps_json ? (() => { try { const parsed = JSON.parse(row.steps_json); return Array.isArray(parsed) ? parsed : []; } catch (error) { return []; } })() : [], runs_count: row.runs_count || 0, interval_minutes: clampAutomationIntervalMinutes(row.interval_minutes), jitter_minutes: clampAutomationJitterMinutes(row.jitter_minutes || 0), start_at: row.start_at ? ensureIsoDate(row.start_at) : null, run_until: row.run_until ? ensureIsoDate(row.run_until) : null, exclusion_windows: parseExclusionWindows(row.exclusion_windows_json || row.exclusion_windows || []), active: row.active ? 1 : 0, last_run_at: row.last_run_at ? ensureIsoDate(row.last_run_at) : null, last_status: row.last_status || null, last_status_code: row.last_status_code || null, last_error: row.last_error || null, next_run_at: row.next_run_at ? ensureIsoDate(row.next_run_at) : null, created_at: row.created_at ? ensureIsoDate(row.created_at) : null, updated_at: row.updated_at ? ensureIsoDate(row.updated_at) : null }; } function serializeAutomationRun(row) { if (!row) { return null; } return { id: row.id, request_id: row.request_id, trigger: row.trigger || 'schedule', started_at: row.started_at ? ensureIsoDate(row.started_at) : null, completed_at: row.completed_at ? ensureIsoDate(row.completed_at) : null, status: row.status || null, status_code: row.status_code || null, error: row.error || null, response_body: row.response_body || '', duration_ms: row.duration_ms || null }; } function normalizeAutomationPayload(payload, existing = {}) { const errors = []; const normalized = {}; const rawType = typeof payload.type === 'string' ? payload.type.trim().toLowerCase() : (existing.type || AUTOMATION_TYPE_REQUEST); const type = [AUTOMATION_TYPE_REQUEST, AUTOMATION_TYPE_EMAIL, AUTOMATION_TYPE_FLOW].includes(rawType) ? rawType : AUTOMATION_TYPE_REQUEST; normalized.type = type; const nameSource = typeof payload.name === 'string' ? payload.name : existing.name || ''; const name = nameSource.trim(); if (!name) { errors.push('Name ist erforderlich'); } else { normalized.name = truncateString(name, AUTOMATION_MAX_NAME_LENGTH); } const descriptionSource = typeof payload.description === 'string' ? payload.description.trim() : (existing.description || ''); normalized.description = truncateString(descriptionSource || '', 800); const rawMethod = typeof payload.method === 'string' ? payload.method.trim().toUpperCase() : (existing.method || 'GET'); const allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; if (!allowedMethods.includes(rawMethod)) { errors.push('Ungültige HTTP-Methode'); } else { normalized.method = rawMethod; } const rawUrl = typeof payload.url_template === 'string' ? payload.url_template : (typeof payload.url === 'string' ? payload.url : existing.url_template || ''); const urlTemplate = (rawUrl || '').trim(); if (type === AUTOMATION_TYPE_REQUEST || type === AUTOMATION_TYPE_FLOW) { if (!urlTemplate && type === AUTOMATION_TYPE_REQUEST) { errors.push('URL-Template fehlt'); } normalized.url_template = urlTemplate ? truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH) : null; } else { normalized.url_template = null; } const headersInput = payload.headers || payload.headers_json || payload.headersText || null; if (headersInput) { const parsedHeaders = normalizeAutomationHeaders(headersInput); const serializedHeaders = serializeAutomationHeaders(parsedHeaders); if (serializedHeaders === null && Object.keys(parsedHeaders).length) { errors.push('Headers sind zu groß oder ungültig'); } normalized.headers_json = serializedHeaders; } else { normalized.headers_json = existing.headers_json || null; } if (typeof payload.body_template === 'string') { normalized.body_template = truncateString(payload.body_template, AUTOMATION_MAX_BODY_LENGTH); } else if (typeof payload.body === 'string') { normalized.body_template = truncateString(payload.body, AUTOMATION_MAX_BODY_LENGTH); } else { normalized.body_template = typeof existing.body_template === 'string' ? existing.body_template : ''; } if (type === AUTOMATION_TYPE_EMAIL) { const toValue = (payload.email_to || payload.to || existing.email_to || '').trim(); if (!toValue) { errors.push('E-Mail Empfänger fehlt'); } else { normalized.email_to = truncateString(toValue, AUTOMATION_MAX_EMAIL_TO_LENGTH); } const subjectValue = (payload.email_subject_template || payload.subject || existing.email_subject_template || '').trim(); if (!subjectValue) { errors.push('E-Mail Betreff fehlt'); } else { normalized.email_subject_template = truncateString(subjectValue, AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH); } const bodyValue = typeof payload.email_body_template === 'string' ? payload.email_body_template : (typeof payload.body_template === 'string' ? payload.body_template : existing.email_body_template || ''); if (!bodyValue || !String(bodyValue).trim()) { errors.push('E-Mail Body fehlt'); } else { normalized.email_body_template = truncateString(String(bodyValue), AUTOMATION_MAX_BODY_LENGTH); } normalized.steps_json = null; normalized.url_template = ''; normalized.headers_json = null; normalized.body_template = null; } else if (type === AUTOMATION_TYPE_FLOW) { const stepsInput = Array.isArray(payload.steps) ? payload.steps : (typeof payload.steps_json === 'string' ? (() => { try { return JSON.parse(payload.steps_json); } catch (err) { return []; } })() : []); const steps = []; for (const rawStep of stepsInput) { if (!rawStep || typeof rawStep !== 'object') continue; const stepUrl = typeof rawStep.url === 'string' ? rawStep.url.trim() : ''; if (!stepUrl) continue; const stepMethod = typeof rawStep.method === 'string' ? rawStep.method.toUpperCase() : 'GET'; const allowed = allowedMethods.includes(stepMethod) ? stepMethod : 'GET'; const stepHeaders = normalizeAutomationHeaders(rawStep.headers || rawStep.headers_json || {}); const stepHeadersSerialized = serializeAutomationHeaders(stepHeaders); steps.push({ method: allowed, url: truncateString(stepUrl, AUTOMATION_MAX_URL_LENGTH), headers: stepHeadersSerialized ? JSON.parse(stepHeadersSerialized) : {}, body: typeof rawStep.body === 'string' ? truncateString(rawStep.body, AUTOMATION_MAX_BODY_LENGTH) : '' }); if (steps.length >= AUTOMATION_MAX_STEPS) break; } if (!steps.length) { errors.push('Mindestens ein Schritt mit URL ist erforderlich'); } else { try { const serializedSteps = JSON.stringify(steps); normalized.steps_json = serializedSteps; } catch (error) { errors.push('Schritte konnten nicht gespeichert werden'); } } normalized.email_to = null; normalized.email_subject_template = null; normalized.email_body_template = null; normalized.url_template = normalized.url_template || ''; } else { normalized.email_to = null; normalized.email_subject_template = null; normalized.email_body_template = null; normalized.steps_json = null; } const scheduleType = typeof payload.schedule_type === 'string' ? payload.schedule_type : payload.interval_type; let intervalMinutes; if (scheduleType === 'daily') { intervalMinutes = 24 * 60; } else if (scheduleType === 'hourly') { intervalMinutes = 60; } else { intervalMinutes = payload.interval_minutes ?? payload.every_minutes ?? existing.interval_minutes ?? AUTOMATION_DEFAULT_INTERVAL_MINUTES; } normalized.interval_minutes = clampAutomationIntervalMinutes(intervalMinutes); normalized.jitter_minutes = clampAutomationJitterMinutes( payload.jitter_minutes ?? payload.variance_minutes ?? existing.jitter_minutes ?? 0 ); normalized.start_at = parseAutomationDate(payload.start_at) || parseAutomationDate(existing.start_at); normalized.run_until = parseAutomationDate(payload.run_until) || null; normalized.exclusion_windows_json = serializeExclusionWindows( payload.exclusion_windows ?? payload.exclusions ?? payload.exclude_windows, existing.exclusion_windows_json || null ); normalized.active = payload.active === undefined || payload.active === null ? (existing.active ? 1 : 0) : (payload.active ? 1 : 0); return { data: normalized, errors }; } function recordAIUsageEvent(credentialId, eventType, options = {}) { if (!credentialId || !eventType) { return; } try { const { statusCode = null, message = null, metadata = null } = options; let metadataJson = null; if (metadata && typeof metadata === 'object') { try { metadataJson = JSON.stringify(metadata); } catch (error) { metadataJson = null; } } else if (typeof metadata === 'string') { metadataJson = metadata; } db.prepare(` INSERT INTO ai_usage_events (credential_id, event_type, status_code, message, metadata) VALUES (?, ?, ?, ?, ?) `).run( credentialId, eventType, statusCode !== undefined ? statusCode : null, message ? truncateString(message, 512) : null, metadataJson ); } catch (error) { console.warn('Failed to record AI usage event:', error.message); } } function updateCredentialUsageOnSuccess(credentialId, info = {}) { if (!credentialId) { return; } try { const row = db.prepare(` SELECT usage_24h_count, usage_24h_reset_at, auto_disabled, auto_disabled_reason, auto_disabled_until, rate_limit_reset_at FROM ai_credentials WHERE id = ? `).get(credentialId) || {}; const now = new Date(); const nowIso = now.toISOString(); let usageCount = Number(row.usage_24h_count) || 0; let usageResetDate = row.usage_24h_reset_at ? new Date(row.usage_24h_reset_at) : null; if (usageResetDate && usageResetDate <= now) { usageCount = 0; usageResetDate = null; } let rateLimitResetIso = ensureIsoDate(info.rateLimitResetAt || row.rate_limit_reset_at); if (rateLimitResetIso) { const rateReset = new Date(rateLimitResetIso); if (rateReset <= now) { rateLimitResetIso = null; } } if (!usageResetDate) { if (rateLimitResetIso) { usageResetDate = new Date(rateLimitResetIso); } else { usageResetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); } } else if (rateLimitResetIso) { const rateReset = new Date(rateLimitResetIso); if (rateReset < usageResetDate) { usageResetDate = rateReset; } } usageCount += 1; const shouldClearAutoDisable = row.auto_disabled === 1 && (!row.auto_disabled_reason || String(row.auto_disabled_reason).startsWith('AUTO:')); const data = { id: credentialId, last_used_at: nowIso, last_success_at: nowIso, last_status_code: 200, last_rate_limit_remaining: info.rateLimitRemaining || null, rate_limit_reset_at: rateLimitResetIso, usage_24h_count: usageCount, usage_24h_reset_at: usageResetDate ? usageResetDate.toISOString() : null, auto_disabled: shouldClearAutoDisable ? 0 : row.auto_disabled || 0, auto_disabled_reason: shouldClearAutoDisable ? null : row.auto_disabled_reason || null, auto_disabled_until: shouldClearAutoDisable ? null : row.auto_disabled_until || null }; db.prepare(` UPDATE ai_credentials SET last_used_at = @last_used_at, last_success_at = @last_success_at, last_status_code = @last_status_code, last_rate_limit_remaining = @last_rate_limit_remaining, rate_limit_reset_at = @rate_limit_reset_at, usage_24h_count = @usage_24h_count, usage_24h_reset_at = @usage_24h_reset_at, auto_disabled = @auto_disabled, auto_disabled_reason = @auto_disabled_reason, auto_disabled_until = @auto_disabled_until, updated_at = CURRENT_TIMESTAMP WHERE id = @id `).run(data); recordAIUsageEvent(credentialId, 'success', { statusCode: 200, message: 'Kommentar erfolgreich generiert', metadata: { rateLimitRemaining: info.rateLimitRemaining || null, rateLimitResetAt: rateLimitResetIso, usage24hCount: usageCount } }); } catch (error) { console.warn('Failed to update credential success stats:', error.message); } } function updateCredentialUsageOnError(credentialId, error) { if (!credentialId || !error) { return { autoDisabled: false, autoDisabledUntil: null }; } let decision = null; try { decision = determineAutoDisable(error); const now = new Date(); const nowIso = now.toISOString(); const rateLimitResetIso = ensureIsoDate(error.rateLimitResetAt); const rateLimitRemaining = error.rateLimitRemaining !== undefined && error.rateLimitRemaining !== null ? String(error.rateLimitRemaining) : null; const updateData = { id: credentialId, last_used_at: nowIso, last_error_message: truncateString(error.message || 'Unbekannter Fehler', 512), last_error_at: nowIso, last_status_code: error.status || error.statusCode || null, last_rate_limit_remaining: rateLimitRemaining, rate_limit_reset_at: rateLimitResetIso, auto_disabled: decision ? 1 : null, auto_disabled_reason: decision ? `AUTO:${decision.reason}` : null, auto_disabled_until: decision ? decision.until.toISOString() : null }; db.prepare(` UPDATE ai_credentials SET last_used_at = @last_used_at, last_error_message = @last_error_message, last_error_at = @last_error_at, last_status_code = @last_status_code, last_rate_limit_remaining = @last_rate_limit_remaining, rate_limit_reset_at = @rate_limit_reset_at, auto_disabled = CASE WHEN @auto_disabled IS NULL THEN auto_disabled ELSE @auto_disabled END, auto_disabled_reason = CASE WHEN @auto_disabled_reason IS NULL THEN auto_disabled_reason ELSE @auto_disabled_reason END, auto_disabled_until = CASE WHEN @auto_disabled_until IS NULL THEN auto_disabled_until ELSE @auto_disabled_until END, updated_at = CURRENT_TIMESTAMP WHERE id = @id `).run(updateData); const eventMetadata = { provider: error.provider || null, retryAfterSeconds: error.retryAfterSeconds || null, rateLimitResetAt: rateLimitResetIso, rateLimitRemaining, autoDisabled: Boolean(decision) }; recordAIUsageEvent(credentialId, 'error', { statusCode: error.status || error.statusCode || null, message: error.message || 'Unbekannter Fehler', metadata: eventMetadata }); if (decision) { recordAIUsageEvent(credentialId, 'auto_disabled', { statusCode: error.status || error.statusCode || null, message: decision.reason, metadata: { autoDisabledUntil: decision.until.toISOString(), retryAfterSeconds: decision.seconds } }); } } catch (updateError) { console.warn('Failed to update credential error stats:', updateError.message); } return { autoDisabled: Boolean(decision), autoDisabledUntil: decision ? decision.until.toISOString() : null }; } function reactivateExpiredCredentials() { try { const nowIso = new Date().toISOString(); const rows = db.prepare(` SELECT id, auto_disabled_until FROM ai_credentials WHERE auto_disabled = 1 AND auto_disabled_until IS NOT NULL AND auto_disabled_until <= ? `).all(nowIso); for (const row of rows) { db.prepare(` UPDATE ai_credentials SET auto_disabled = 0, auto_disabled_reason = NULL, auto_disabled_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(row.id); recordAIUsageEvent(row.id, 'auto_reenabled', { message: 'Automatisch wieder aktiviert', metadata: { previousUntil: row.auto_disabled_until } }); } } catch (error) { console.warn('Failed to reactivate credentials:', error.message); } } const CREDENTIAL_SELECT_BASE = ` SELECT c.id, c.name, c.provider, c.model, c.base_url, c.is_active, c.priority, c.auto_disabled, c.auto_disabled_reason, c.auto_disabled_until, c.last_used_at, c.last_success_at, c.last_error_message, c.last_error_at, c.last_status_code, c.last_rate_limit_remaining, c.rate_limit_reset_at, c.usage_24h_count, c.usage_24h_reset_at, c.created_at, c.updated_at, e.event_type AS latest_event_type, e.status_code AS latest_event_status_code, e.message AS latest_event_message, e.metadata AS latest_event_metadata, e.created_at AS latest_event_at FROM ai_credentials c LEFT JOIN ( SELECT e1.* FROM ai_usage_events e1 INNER JOIN ( SELECT credential_id, MAX(id) AS latest_id FROM ai_usage_events GROUP BY credential_id ) latest ON latest.credential_id = e1.credential_id AND latest.latest_id = e1.id ) e ON e.credential_id = c.id `; function fetchCredentialRows(options = {}) { const { where = '', params = [], orderBy = 'ORDER BY c.priority ASC, c.id ASC' } = options; const query = `${CREDENTIAL_SELECT_BASE} ${where ? where : ''} ${orderBy}`; return db.prepare(query).all(...params); } function formatCredentialRow(row) { if (!row) { return null; } const now = Date.now(); const formatted = { ...row }; formatted.is_active = Number(row.is_active) === 1 ? 1 : 0; formatted.auto_disabled = Number(row.auto_disabled) === 1; let cooldownSeconds = null; if (row.auto_disabled_until) { const until = Date.parse(row.auto_disabled_until); if (!Number.isNaN(until)) { const diffSeconds = Math.round((until - now) / 1000); cooldownSeconds = diffSeconds > 0 ? diffSeconds : 0; } } formatted.cooldown_remaining_seconds = cooldownSeconds; if (row.latest_event_type) { let metadata = null; if (row.latest_event_metadata) { try { metadata = JSON.parse(row.latest_event_metadata); } catch (error) { metadata = row.latest_event_metadata; } } formatted.latest_event = { type: row.latest_event_type, status_code: row.latest_event_status_code, message: row.latest_event_message, metadata, created_at: row.latest_event_at }; } else { formatted.latest_event = null; } formatted.status = formatted.is_active ? (formatted.auto_disabled ? 'cooldown' : 'active') : 'inactive'; delete formatted.latest_event_type; delete formatted.latest_event_status_code; delete formatted.latest_event_message; delete formatted.latest_event_metadata; delete formatted.latest_event_at; return formatted; } function getAllCredentialsFormatted() { return fetchCredentialRows().map(formatCredentialRow); } function getFormattedCredentialById(id) { const row = fetchCredentialRows({ where: 'WHERE c.id = ?', params: [id], orderBy: '' }); if (!row || !row.length) { return null; } return formatCredentialRow(row[0]); } function normalizeRetentionDays(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed <= 0) { return SEARCH_POST_RETENTION_DAYS; } return Math.min(365, Math.max(1, parsed)); } function loadHiddenSettings() { let settings = db.prepare('SELECT * FROM maintenance_settings WHERE id = 1').get(); if (!settings) { const defaults = { id: 1, search_retention_days: SEARCH_POST_RETENTION_DAYS, auto_purge_hidden: 1 }; db.prepare(` INSERT INTO maintenance_settings (id, search_retention_days, auto_purge_hidden, updated_at) VALUES (1, ?, ?, CURRENT_TIMESTAMP) `).run(defaults.search_retention_days, defaults.auto_purge_hidden); settings = defaults; } settings.search_retention_days = normalizeRetentionDays(settings.search_retention_days); settings.auto_purge_hidden = settings.auto_purge_hidden ? 1 : 0; return settings; } function persistHiddenSettings({ retentionDays, autoPurgeEnabled }) { const normalizedRetention = normalizeRetentionDays(retentionDays); const normalizedAuto = autoPurgeEnabled ? 1 : 0; const existing = db.prepare('SELECT id FROM maintenance_settings WHERE id = 1').get(); if (existing) { db.prepare(` UPDATE maintenance_settings SET search_retention_days = ?, auto_purge_hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `).run(normalizedRetention, normalizedAuto); } else { db.prepare(` INSERT INTO maintenance_settings (id, search_retention_days, auto_purge_hidden, updated_at) VALUES (1, ?, ?, CURRENT_TIMESTAMP) `).run(normalizedRetention, normalizedAuto); } return { auto_purge_enabled: !!normalizedAuto, retention_days: normalizedRetention }; } function cleanupExpiredSearchPosts() { try { const settings = loadHiddenSettings(); if (!settings.auto_purge_hidden) { return; } const threshold = `-${settings.search_retention_days} day`; db.prepare(` DELETE FROM search_seen_posts WHERE last_seen_at < DATETIME('now', ?) `).run(threshold); } catch (error) { console.warn('Failed to cleanup expired search posts:', error.message); } } function safeParseSportsWeights(raw) { if (!raw) { return null; } if (typeof raw === 'object' && !Array.isArray(raw)) { return raw; } if (typeof raw !== 'string') { return null; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed; } } catch (error) { return null; } return null; } function normalizeSportsScoreThreshold(value) { const parsed = parseFloat(value); if (Number.isNaN(parsed) || parsed < 0) { return SPORTS_SCORING_DEFAULTS.threshold; } return Math.min(50, Math.max(0, parsed)); } function normalizeSportsWeights(weights) { const defaults = SPORTS_SCORING_DEFAULTS.weights; const normalized = {}; const source = (weights && typeof weights === 'object' && !Array.isArray(weights)) ? weights : {}; for (const key of Object.keys(defaults)) { const raw = source[key]; const parsed = typeof raw === 'number' ? raw : parseFloat(raw); const value = Number.isFinite(parsed) ? parsed : defaults[key]; normalized[key] = Math.max(0, Math.min(10, value)); } return normalized; } function normalizeSportsTerms(terms) { const defaults = SPORTS_SCORING_TERMS_DEFAULTS; const result = {}; const source = (terms && typeof terms === 'object' && !Array.isArray(terms)) ? terms : {}; const normalizeList = (list, fallback) => { const arr = Array.isArray(list) ? list : []; const cleaned = arr .map((entry) => { if (typeof entry !== 'string') return ''; return entry.trim().toLowerCase(); }) .filter((entry) => entry && entry.length <= 60); const unique = Array.from(new Set(cleaned)).slice(0, 200); if (unique.length) { return unique; } return fallback.slice(); }; for (const key of Object.keys(defaults)) { result[key] = normalizeList(source[key], defaults[key]); } return result; } function loadModerationSettings() { let settings = db.prepare('SELECT * FROM moderation_settings WHERE id = 1').get(); if (!settings) { const serializedWeights = JSON.stringify(SPORTS_SCORING_DEFAULTS.weights); const serializedTerms = JSON.stringify(SPORTS_SCORING_TERMS_DEFAULTS); db.prepare(` INSERT INTO moderation_settings (id, sports_scoring_enabled, sports_score_threshold, sports_auto_hide_enabled, sports_score_weights, sports_terms, updated_at) VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `).run(SPORTS_SCORING_DEFAULTS.enabled, SPORTS_SCORING_DEFAULTS.threshold, SPORTS_SCORING_DEFAULTS.auto_hide_enabled, serializedWeights, serializedTerms); settings = { id: 1, sports_scoring_enabled: SPORTS_SCORING_DEFAULTS.enabled, sports_score_threshold: SPORTS_SCORING_DEFAULTS.threshold, sports_auto_hide_enabled: SPORTS_SCORING_DEFAULTS.auto_hide_enabled, sports_score_weights: serializedWeights, sports_terms: serializedTerms }; } const weights = normalizeSportsWeights(safeParseSportsWeights(settings.sports_score_weights)); const threshold = normalizeSportsScoreThreshold(settings.sports_score_threshold); let terms = SPORTS_SCORING_TERMS_DEFAULTS; try { const parsedTerms = settings.sports_terms ? JSON.parse(settings.sports_terms) : null; terms = normalizeSportsTerms(parsedTerms); } catch (error) { terms = SPORTS_SCORING_TERMS_DEFAULTS; } return { sports_scoring_enabled: !!settings.sports_scoring_enabled, sports_score_threshold: threshold, sports_auto_hide_enabled: !!settings.sports_auto_hide_enabled, sports_score_weights: weights, sports_terms: terms }; } function persistModerationSettings({ enabled, threshold, weights, terms, autoHide }) { const normalizedEnabled = enabled ? 1 : 0; const normalizedAutoHide = autoHide ? 1 : 0; const normalizedThreshold = normalizeSportsScoreThreshold(threshold); const normalizedWeights = normalizeSportsWeights(weights); const serializedWeights = JSON.stringify(normalizedWeights); const normalizedTerms = normalizeSportsTerms(terms); const serializedTerms = JSON.stringify(normalizedTerms); const existing = db.prepare('SELECT id FROM moderation_settings WHERE id = 1').get(); if (existing) { db.prepare(` UPDATE moderation_settings SET sports_scoring_enabled = ?, sports_score_threshold = ?, sports_auto_hide_enabled = ?, sports_score_weights = ?, sports_terms = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `).run(normalizedEnabled, normalizedThreshold, normalizedAutoHide, serializedWeights, serializedTerms); } else { db.prepare(` INSERT INTO moderation_settings (id, sports_scoring_enabled, sports_score_threshold, sports_auto_hide_enabled, sports_score_weights, sports_terms, updated_at) VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `).run(normalizedEnabled, normalizedThreshold, normalizedAutoHide, serializedWeights, serializedTerms); } return { sports_scoring_enabled: !!normalizedEnabled, sports_score_threshold: normalizedThreshold, sports_auto_hide_enabled: !!normalizedAutoHide, sports_score_weights: normalizedWeights, sports_terms: normalizedTerms }; } function expandPhotoUrlHostVariants(url) { if (typeof url !== 'string' || !url) { return []; } try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); if (!hostname.endsWith('facebook.com')) { return []; } const pathname = parsed.pathname.toLowerCase(); if (!pathname.startsWith('/photo')) { return []; } const protocol = parsed.protocol || 'https:'; const search = parsed.search || ''; const hosts = ['www.facebook.com', 'facebook.com', 'm.facebook.com']; const variants = []; for (const candidateHost of hosts) { if (candidateHost === hostname) { continue; } const candidateUrl = `${protocol}//${candidateHost}${parsed.pathname}${search}`; const normalized = normalizeFacebookPostUrl(candidateUrl); if (normalized && normalized !== url && !variants.includes(normalized)) { variants.push(normalized); } } return variants; } catch (error) { return []; } } function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { const normalized = []; const pushNormalized = (value, expandVariants = true) => { const normalizedUrl = normalizeFacebookPostUrl(value); if (normalizedUrl && !normalized.includes(normalizedUrl)) { normalized.push(normalizedUrl); if (expandVariants) { const photoVariants = expandPhotoUrlHostVariants(normalizedUrl); for (const variant of photoVariants) { pushNormalized(variant, false); } } } }; if (primaryUrl) { pushNormalized(primaryUrl); } if (Array.isArray(candidates)) { for (const candidate of candidates) { pushNormalized(candidate); } } return normalized; } function collectPostAlternateUrls(primaryUrl, candidates = []) { const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl); if (!normalizedPrimary) { return []; } const primaryKey = extractFacebookContentKey(normalizedPrimary); const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates); return normalized.filter((url) => { if (url === normalizedPrimary) { return false; } const candidateKey = extractFacebookContentKey(url); return candidateKey && candidateKey === primaryKey; }); } const insertPostUrlStmt = db.prepare(` INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) VALUES (?, ?, 0) `); const selectPostByPrimaryUrlStmt = db.prepare('SELECT * FROM posts WHERE url = ?'); const selectPostByAlternateUrlStmt = db.prepare(` SELECT p.* FROM post_urls pu JOIN posts p ON p.id = pu.post_id WHERE pu.url = ? LIMIT 1 `); const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); const selectPostIdByAlternateUrlStmt = db.prepare('SELECT post_id FROM post_urls WHERE url = ?'); const selectPostByContentKeyStmt = db.prepare('SELECT * FROM posts WHERE content_key = ? LIMIT 1'); const selectPostIdByContentKeyStmt = db.prepare('SELECT id FROM posts WHERE content_key = ? LIMIT 1'); const selectAlternateUrlsForPostStmt = db.prepare(` SELECT url FROM post_urls WHERE post_id = ? ORDER BY created_at ASC `); const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?'); const selectChecksForPostStmt = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?'); const updateCheckPostStmt = db.prepare('UPDATE checks SET post_id = ? WHERE id = ?'); const updateCheckTimestampStmt = db.prepare('UPDATE checks SET checked_at = ? WHERE id = ?'); const deleteCheckByIdStmt = db.prepare('DELETE FROM checks WHERE id = ?'); function storePostUrls(postId, primaryUrl, additionalUrls = [], options = {}) { const { skipContentKeyCheck = false } = options; if (!postId || !primaryUrl) { return; } const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl); if (!normalizedPrimary) { return; } const primaryKey = extractFacebookContentKey(normalizedPrimary); if (Array.isArray(additionalUrls)) { for (const candidate of additionalUrls) { const normalized = normalizeFacebookPostUrl(candidate); if (!normalized || normalized === normalizedPrimary) { continue; } if (!skipContentKeyCheck) { const candidateKey = extractFacebookContentKey(normalized); if (!candidateKey || candidateKey !== primaryKey) { continue; } } const existingPostId = findPostIdByUrl(normalized); if (existingPostId && existingPostId !== postId) { continue; } insertPostUrlStmt.run(postId, normalized); } } } function findPostIdByUrl(normalizedUrl) { if (!normalizedUrl) { return null; } const primaryRow = selectPostIdByPrimaryUrlStmt.get(normalizedUrl); if (primaryRow && primaryRow.id) { return primaryRow.id; } const alternateRow = selectPostIdByAlternateUrlStmt.get(normalizedUrl); if (alternateRow && alternateRow.post_id) { return alternateRow.post_id; } const contentKey = extractFacebookContentKey(normalizedUrl); if (contentKey) { const contentRow = selectPostIdByContentKeyStmt.get(contentKey); if (contentRow && contentRow.id) { return contentRow.id; } } return null; } function findPostByUrl(normalizedUrl) { if (!normalizedUrl) { return null; } const primary = selectPostByPrimaryUrlStmt.get(normalizedUrl); if (primary) { return primary; } const alternate = selectPostByAlternateUrlStmt.get(normalizedUrl); if (alternate) { return alternate; } const contentKey = extractFacebookContentKey(normalizedUrl); if (contentKey) { const contentMatch = selectPostByContentKeyStmt.get(contentKey); if (contentMatch) { return contentMatch; } } return null; } function removeSearchSeenEntries(urls) { if (!Array.isArray(urls) || urls.length === 0) { return; } const uniqueValidUrls = Array.from(new Set(urls.filter(url => typeof url === 'string' && url.trim()))); if (!uniqueValidUrls.length) { return; } const stmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?'); const runDeletion = db.transaction((values) => { for (const value of values) { stmt.run(value); } }); try { runDeletion(uniqueValidUrls); } catch (error) { console.warn('Failed to remove search seen entries:', error.message); } } cleanupExpiredSearchPosts(); const selectSearchSeenStmt = db.prepare('SELECT url, seen_count, manually_hidden, sports_auto_hidden, first_seen_at, last_seen_at FROM search_seen_posts WHERE url = ?'); const insertSearchSeenStmt = db.prepare(` INSERT INTO search_seen_posts (url, seen_count, manually_hidden, sports_auto_hidden, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `); const updateSearchSeenStmt = db.prepare(` UPDATE search_seen_posts SET seen_count = ?, manually_hidden = ?, sports_auto_hidden = ?, last_seen_at = CURRENT_TIMESTAMP WHERE url = ? `); const checkIndexes = db.prepare("PRAGMA index_list('checks')").all(); for (const idx of checkIndexes) { if (idx.unique) { // Skip auto indexes created from PRIMARY KEY/UNIQUE constraints; SQLite refuses to drop them if (idx.origin !== 'c' || (idx.name && idx.name.startsWith('sqlite_autoindex'))) { continue; } const info = db.prepare(`PRAGMA index_info('${idx.name}')`).all(); const columns = info.map(i => i.name).join(','); if (columns === 'post_id,profile_number' || columns === 'profile_number,post_id') { db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); } } } function sqliteTimestampToUTC(timestamp) { if (!timestamp) { return null; } // SQLite CURRENT_TIMESTAMP returns UTC time in format "YYYY-MM-DD HH:MM:SS" // Convert to ISO-8601 with Z suffix to indicate UTC return timestamp.replace(' ', 'T') + 'Z'; } function mapPostRow(post) { if (!post) { return null; } let postContentKey = post.content_key; if (!postContentKey) { const normalizedUrl = normalizeFacebookPostUrl(post.url); postContentKey = extractFacebookContentKey(normalizedUrl); if (postContentKey) { updateContentKeyStmt.run(postContentKey, post.id); post.content_key = postContentKey; } } if (post.post_text && (!post.post_text_hash || !post.post_text_hash.trim())) { const normalizedPostText = normalizePostText(post.post_text); const hash = computePostTextHash(normalizedPostText); post.post_text = normalizedPostText; post.post_text_hash = hash; updatePostTextColumnsStmt.run(normalizedPostText, hash, post.id); } const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); const checkedCount = statuses.filter(status => status.status === 'done').length; const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) ? `/api/posts/${post.id}/screenshot` : null; let postLastChange = post.last_change; if (post.checked_count !== checkedCount || post.target_count !== requiredProfiles.length) { const updates = []; const params = []; if (post.checked_count !== checkedCount) { updates.push('checked_count = ?'); params.push(checkedCount); } if (post.target_count !== requiredProfiles.length) { updates.push('target_count = ?'); params.push(requiredProfiles.length); } updates.push('last_change = CURRENT_TIMESTAMP'); params.push(post.id); db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params); const refreshed = db.prepare('SELECT last_change FROM posts WHERE id = ?').get(post.id); if (refreshed && refreshed.last_change) { postLastChange = refreshed.last_change; } } const nextRequired = statuses.find(status => status.status === 'available'); const creatorProfile = sanitizeProfileNumber(post.created_by_profile); const creatorName = normalizeCreatorName(post.created_by_name); // Convert SQLite timestamps to UTC ISO-8601 format const checksWithUTC = completedChecks.map(check => ({ ...check, checked_at: sqliteTimestampToUTC(check.checked_at) })); const statusesWithUTC = statuses.map(status => ({ ...status, checked_at: sqliteTimestampToUTC(status.checked_at) })); const alternateUrlRows = selectAlternateUrlsForPostStmt.all(post.id); const alternateUrls = alternateUrlRows.map(row => row.url); return { ...post, created_at: sqliteTimestampToUTC(post.created_at), target_count: requiredProfiles.length, checked_count: checkedCount, last_change: sqliteTimestampToUTC(postLastChange), checks: checksWithUTC, is_complete: checkedCount >= requiredProfiles.length, screenshot_path: screenshotPath, required_profiles: requiredProfiles, profile_statuses: statusesWithUTC, next_required_profile: nextRequired ? nextRequired.profile_number : null, created_by_profile: creatorProfile, created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null, created_by_name: creatorName, deadline_at: post.deadline_at || null, alternate_urls: alternateUrls, post_text: post.post_text || null, post_text_hash: post.post_text_hash || null, content_key: post.content_key || postContentKey || null }; } function broadcastPostChange(post, options = {}) { if (!post || !post.id) { return; } const payload = { type: 'post-upsert', post }; if (options && options.reason) { payload.reason = options.reason; } broadcastSseEvent(payload); } function broadcastPostChangeById(postId, options = {}) { if (!postId) { return; } try { const row = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!row) { return; } const postPayload = mapPostRow(row); broadcastPostChange(postPayload, options); } catch (error) { console.warn(`Failed to broadcast post ${postId}:`, error.message); } } function broadcastPostDeletion(postId, options = {}) { if (!postId) { return; } const payload = { type: 'post-deleted', postId }; if (options && options.reason) { payload.reason = options.reason; } broadcastSseEvent(payload); } function broadcastAutomationEvent(eventType, payload = {}) { if (!eventType) { return; } const data = { type: eventType, ...payload }; broadcastSseEvent(data); } const automationRunningRequests = new Set(); let automationWorkerTimer = null; let automationWorkerBusy = false; async function executeAutomationRequest(request, options = {}) { if (!request || !request.id) { return null; } const trigger = options.trigger || 'schedule'; const allowInactive = !!options.allowInactive; const current = getAutomationRequestStmt.get(request.id); if (!current || (!current.active && !allowInactive)) { return null; } if (automationRunningRequests.has(current.id)) { return null; } automationRunningRequests.add(current.id); const context = buildAutomationTemplateContext(new Date()); const startedAt = new Date(); let status = 'success'; let statusCode = null; let responseText = ''; let errorMessage = null; const stepResults = []; async function executeHttpStep(step, stepIndex, inheritedContext) { const method = (step.method || 'GET').toUpperCase(); const url = renderAutomationTemplate(step.url_template || step.url, inheritedContext); const headers = renderAutomationHeaders(step.headers_json || serializeAutomationHeaders(step.headers || {}), inheritedContext); const shouldSendBody = !['GET', 'HEAD'].includes(method); const body = shouldSendBody && (step.body_template || step.body) ? renderAutomationTemplate(step.body_template || step.body, inheritedContext) : null; let localStatus = 'success'; let localStatusCode = null; let localResponseText = ''; let localError = null; try { const response = await fetch(url, { method, headers, body: shouldSendBody && body !== null ? body : undefined, redirect: 'follow' }); localStatusCode = response.status; try { localResponseText = await response.text(); } catch (error) { localResponseText = ''; } if (!response.ok) { localStatus = 'error'; localError = `HTTP ${response.status}`; } } catch (error) { localStatus = 'error'; localError = error.message || 'Unbekannter Fehler'; } let parsedJson = null; if (localResponseText) { try { parsedJson = JSON.parse(localResponseText); } catch (error) { parsedJson = null; } } const resultContext = { ...inheritedContext, [`step${stepIndex}_text`]: localResponseText, [`step${stepIndex}_status`]: localStatus, [`step${stepIndex}_status_code`]: localStatusCode, [`step${stepIndex}_json`]: parsedJson || null }; return { status: localStatus, statusCode: localStatusCode, responseText: localResponseText, error: localError, context: resultContext }; } async function executeEmailStep(stepContext) { if (!nodemailer) { throw new Error('E-Mail Versand nicht verfügbar (nodemailer fehlt)'); } const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE, SMTP_FROM } = process.env; const configSmtp = (automationConfig && automationConfig.smtp) || {}; const host = SMTP_HOST || configSmtp.host; if (!host) { throw new Error('SMTP_HOST nicht gesetzt und keine Konfiguration in automation-config.json'); } const port = SMTP_PORT ? Number(SMTP_PORT) : (configSmtp.port || 587); const secure = SMTP_SECURE === 'true' || SMTP_SECURE === '1' ? true : (typeof configSmtp.secure === 'boolean' ? configSmtp.secure : port === 465); const user = SMTP_USER || configSmtp.user || ''; const pass = SMTP_PASS || configSmtp.pass || ''; const from = SMTP_FROM || configSmtp.from || user || 'automation@example.com'; const transporter = nodemailer.createTransport({ host, port, secure, auth: user ? { user, pass } : undefined }); const to = renderAutomationTemplate(current.email_to || '', stepContext); const subject = renderAutomationTemplate(current.email_subject_template || '', stepContext); const body = renderAutomationTemplate(current.email_body_template || '', stepContext); const bodyHtml = body ? body.replace(/\n/g, '
') : ''; if (!to || !subject || !body) { throw new Error('E-Mail Felder unvollständig'); } const info = await transporter.sendMail({ from, to, subject, text: body, html: bodyHtml || body }); return { status: 'success', statusCode: info && info.accepted ? 200 : null, responseText: info ? JSON.stringify({ messageId: info.messageId }) : '', error: null, context: { ...stepContext } }; } try { if (current.type === AUTOMATION_TYPE_EMAIL) { const emailResult = await executeEmailStep(context); status = emailResult.status; statusCode = emailResult.statusCode; responseText = emailResult.responseText; errorMessage = emailResult.error; } else if (current.type === AUTOMATION_TYPE_FLOW) { let flowContext = { ...context }; const steps = current.steps_json ? (() => { try { const parsed = JSON.parse(current.steps_json); return Array.isArray(parsed) ? parsed : []; } catch (error) { return []; } })() : []; let stepIndex = 1; for (const step of steps) { const stepResult = await executeHttpStep({ method: step.method || step.http_method || 'GET', url_template: step.url_template || step.url, headers_json: serializeAutomationHeaders(step.headers || {}), body_template: step.body_template || step.body }, stepIndex, flowContext); stepResults.push(stepResult); flowContext = { ...flowContext, ...stepResult.context }; if (stepResult.status === 'error') { status = 'error'; errorMessage = stepResult.error; statusCode = stepResult.statusCode; responseText = stepResult.responseText; break; } else { statusCode = stepResult.statusCode; responseText = stepResult.responseText; } stepIndex += 1; } } else { const result = await executeHttpStep({ method: current.method, url_template: current.url_template, headers_json: current.headers_json, body_template: current.body_template }, 1, context); status = result.status; statusCode = result.statusCode; responseText = result.responseText; errorMessage = result.error; stepResults.push(result); } } catch (error) { status = 'error'; errorMessage = error.message || 'Unbekannter Fehler'; } const completedAt = new Date(); const runRecord = { request_id: current.id, trigger, started_at: startedAt.toISOString(), completed_at: completedAt.toISOString(), status, status_code: statusCode, error: truncateString(errorMessage, 900), response_body: truncateString(responseText, AUTOMATION_MAX_RESPONSE_PREVIEW), duration_ms: Math.max(0, completedAt.getTime() - startedAt.getTime()) }; try { insertAutomationRunStmt.run(runRecord); } catch (error) { console.warn('Failed to persist automation run:', error.message); } const nextRunAt = current.active ? computeNextAutomationRun( { ...current, last_run_at: runRecord.started_at }, { fromDate: completedAt } ) : null; broadcastAutomationEvent('automation-run', { request_id: current.id, status, status_code: statusCode, next_run_at: nextRunAt, last_run_at: runRecord.started_at, runs_count: (current.runs_count || 0) + 1 }); try { updateAutomationRequestStmt.run({ ...current, last_run_at: runRecord.started_at, last_status: status, last_status_code: statusCode, last_error: runRecord.error, next_run_at: nextRunAt }); } catch (error) { console.warn(`Failed to update automation request ${current.id}:`, error.message); } finally { automationRunningRequests.delete(current.id); } return runRecord; } async function processAutomationQueue() { if (automationWorkerBusy) { return; } automationWorkerBusy = true; try { const nowIso = new Date().toISOString(); const dueRequests = listDueAutomationRequestsStmt.all({ now: nowIso }) || []; for (const item of dueRequests) { await executeAutomationRequest(item, { trigger: 'schedule' }); } } catch (error) { console.error('Automation worker failed:', error); } finally { automationWorkerBusy = false; } } function startAutomationWorker() { if (automationWorkerTimer) { return; } automationWorkerTimer = setInterval(() => { processAutomationQueue().catch((error) => { console.error('Automation queue tick failed:', error); }); }, AUTOMATION_WORKER_INTERVAL_MS); processAutomationQueue().catch((error) => { console.error('Automation initial run failed:', error); }); } app.get('/api/automation/requests', (req, res) => { try { const rows = listAutomationRequestsStmt.all(); const hydrated = rows.map((row) => { ensureNextRunForRequest(row, { fromDate: new Date() }); return serializeAutomationRequest(row); }); res.json(hydrated); } catch (error) { console.error('Failed to load automation requests:', error); res.status(500).json({ error: 'Automationen konnten nicht geladen werden' }); } }); app.get('/api/automation/requests/:requestId', (req, res) => { const { requestId } = req.params; try { const row = getAutomationRequestStmt.get(requestId); if (!row) { return res.status(404).json({ error: 'Automation nicht gefunden' }); } const payload = serializeAutomationRequest(row); ensureNextRunForRequest(row, { fromDate: new Date() }); if (req.query && req.query.includeRuns) { const limit = 10; const runs = listAutomationRunsStmt.all({ requestId, limit }).map(serializeAutomationRun); payload.runs = runs; } res.json(payload); } catch (error) { console.error('Failed to load automation request:', error); res.status(500).json({ error: 'Automation konnte nicht geladen werden' }); } }); app.get('/api/automation/requests/:requestId/runs', (req, res) => { const { requestId } = req.params; const limit = Math.max(1, Math.min(200, parseInt(req.query?.limit, 10) || 30)); try { const rows = listAutomationRunsStmt.all({ requestId, limit }); res.json(rows.map(serializeAutomationRun)); } catch (error) { console.error('Failed to load automation runs:', error); res.status(500).json({ error: 'Runs konnten nicht geladen werden' }); } }); app.post('/api/automation/requests', (req, res) => { const payload = req.body || {}; const { data, errors } = normalizeAutomationPayload(payload, {}); if (errors.length) { return res.status(400).json({ error: errors[0], details: errors }); } try { const id = uuidv4(); let nextRunAt = data.active ? computeNextAutomationRun({ ...data, last_run_at: null }, { fromDate: new Date() }) : null; insertAutomationRequestStmt.run({ ...data, id, last_run_at: null, last_status: null, last_status_code: null, last_error: null, next_run_at: nextRunAt }); let saved = getAutomationRequestStmt.get(id); if (data.active && (!saved || !saved.next_run_at)) { const ensured = ensureNextRunForRequest(saved || data, { fromDate: new Date() }); if (ensured) { saved = getAutomationRequestStmt.get(id); } } res.status(201).json(serializeAutomationRequest(saved)); } catch (error) { console.error('Failed to create automation:', error); res.status(500).json({ error: 'Automation konnte nicht erstellt werden' }); } }); app.put('/api/automation/requests/:requestId', (req, res) => { const { requestId } = req.params; const existing = getAutomationRequestStmt.get(requestId); if (!existing) { return res.status(404).json({ error: 'Automation nicht gefunden' }); } const { data, errors } = normalizeAutomationPayload(req.body || {}, existing); if (errors.length) { return res.status(400).json({ error: errors[0], details: errors }); } const merged = { ...existing, ...data, last_run_at: existing.last_run_at, last_status: existing.last_status, last_status_code: existing.last_status_code, last_error: existing.last_error }; merged.next_run_at = merged.active ? computeNextAutomationRun(merged, { fromDate: new Date() }) : null; try { updateAutomationRequestStmt.run(merged); let saved = getAutomationRequestStmt.get(requestId); if (merged.active && (!saved || !saved.next_run_at)) { const ensured = ensureNextRunForRequest(saved || merged, { fromDate: new Date() }); if (ensured) { saved = getAutomationRequestStmt.get(requestId); } } res.json(serializeAutomationRequest(saved)); } catch (error) { console.error('Failed to update automation:', error); res.status(500).json({ error: 'Automation konnte nicht aktualisiert werden' }); } }); app.post('/api/automation/requests/:requestId/run', async (req, res) => { const { requestId } = req.params; const existing = getAutomationRequestStmt.get(requestId); if (!existing) { return res.status(404).json({ error: 'Automation nicht gefunden' }); } try { const run = await executeAutomationRequest(existing, { trigger: 'manual', allowInactive: true }); const refreshed = getAutomationRequestStmt.get(requestId); res.json({ request: serializeAutomationRequest(refreshed), run: serializeAutomationRun(run) }); } catch (error) { console.error('Failed to trigger automation run:', error); res.status(500).json({ error: 'Automation-Run konnte nicht gestartet werden' }); } }); app.delete('/api/automation/requests/:requestId', (req, res) => { const { requestId } = req.params; try { const result = deleteAutomationRequestStmt.run(requestId); if (!result.changes) { return res.status(404).json({ error: 'Automation nicht gefunden' }); } res.status(204).send(); } catch (error) { console.error('Failed to delete automation:', error); res.status(500).json({ error: 'Automation konnte nicht gelöscht werden' }); } }); app.get('/api/bookmarks', (req, res) => { try { const rows = listBookmarksStmt.all(); res.json(rows.map(serializeBookmark)); } catch (error) { console.error('Failed to load bookmarks:', error); res.status(500).json({ error: 'Bookmarks konnten nicht geladen werden' }); } }); app.post('/api/bookmarks', (req, res) => { try { const payload = req.body || {}; const normalizedQuery = normalizeBookmarkQuery(payload.query); if (!normalizedQuery) { return res.status(400).json({ error: 'Ungültiger Suchbegriff' }); } if (findBookmarkByQueryStmt.get(normalizedQuery)) { return res.status(409).json({ error: 'Bookmark existiert bereits' }); } const normalizedLabel = normalizeBookmarkLabel(payload.label, normalizedQuery); const id = uuidv4(); insertBookmarkStmt.run(id, normalizedLabel, normalizedQuery); const saved = getBookmarkByIdStmt.get(id); res.status(201).json(serializeBookmark(saved)); } catch (error) { console.error('Failed to create bookmark:', error); res.status(500).json({ error: 'Bookmark konnte nicht erstellt werden' }); } }); app.post('/api/bookmarks/:bookmarkId/click', (req, res) => { const { bookmarkId } = req.params; if (!bookmarkId) { return res.status(400).json({ error: 'Bookmark-ID fehlt' }); } try { const result = updateBookmarkLastClickedStmt.run(bookmarkId); if (!result.changes) { return res.status(404).json({ error: 'Bookmark nicht gefunden' }); } const updated = getBookmarkByIdStmt.get(bookmarkId); res.json(serializeBookmark(updated)); } catch (error) { console.error('Failed to register bookmark click:', error); res.status(500).json({ error: 'Bookmark konnte nicht aktualisiert werden' }); } }); app.delete('/api/bookmarks/:bookmarkId', (req, res) => { const { bookmarkId } = req.params; if (!bookmarkId) { return res.status(400).json({ error: 'Bookmark-ID fehlt' }); } try { const result = deleteBookmarkStmt.run(bookmarkId); if (!result.changes) { return res.status(404).json({ error: 'Bookmark nicht gefunden' }); } res.status(204).send(); } catch (error) { console.error('Failed to delete bookmark:', error); res.status(500).json({ error: 'Bookmark konnte nicht gelöscht werden' }); } }); app.get('/api/daily-bookmarks', (req, res) => { const dayKey = resolveDayKey(req.query && req.query.day); try { const rows = listDailyBookmarksStmt.all({ dayKey }); res.json(rows.map((row) => serializeDailyBookmark(row, dayKey))); } catch (error) { console.error('Failed to load daily bookmarks:', error); res.status(500).json({ error: 'Daily Bookmarks konnten nicht geladen werden' }); } }); app.post('/api/daily-bookmarks', (req, res) => { const payload = req.body || {}; const rawDay = (payload && payload.day) || (req.query && req.query.day); const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); if (!dayKey) { return res.status(400).json({ error: 'Ungültiges Tagesformat' }); } const normalizedUrl = normalizeDailyBookmarkUrlTemplate(payload.url_template || payload.url); const normalizedTitle = normalizeDailyBookmarkTitle(payload.title || payload.label, normalizedUrl); const normalizedNotes = normalizeDailyBookmarkNotes(payload.notes); const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag); const normalizedActive = normalizeDailyBookmarkActive( payload.is_active ?? payload.active ?? true ); if (!normalizedUrl) { return res.status(400).json({ error: 'URL-Template ist erforderlich' }); } if (findDailyBookmarkByUrlStmt.get(normalizedUrl)) { return res.status(409).json({ error: 'Bookmark mit dieser URL existiert bereits' }); } try { const id = uuidv4(); insertDailyBookmarkStmt.run({ id, title: normalizedTitle, url_template: normalizedUrl, notes: normalizedNotes, marker: normalizedMarker, is_active: normalizedActive }); upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey }); const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey }); res.status(201).json(serializeDailyBookmark(saved, dayKey)); } catch (error) { console.error('Failed to create daily bookmark:', error); res.status(500).json({ error: 'Daily Bookmark konnte nicht erstellt werden' }); } }); app.post('/api/daily-bookmarks/import', (req, res) => { const payload = req.body || {}; const rawDay = (payload && payload.day) || (req.query && req.query.day); const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); if (!dayKey) { return res.status(400).json({ error: 'Ungültiges Tagesformat' }); } const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag); const normalizedActive = normalizeDailyBookmarkActive( payload.is_active ?? payload.active ?? true ); const collected = []; const addValue = (value) => { if (typeof value !== 'string') { return; } const trimmed = value.trim(); if (trimmed) { collected.push(trimmed); } }; if (Array.isArray(payload.urls)) { payload.urls.forEach(addValue); } if (Array.isArray(payload.url_templates)) { payload.url_templates.forEach(addValue); } if (typeof payload.text === 'string') { payload.text.split(/\r?\n|,/).forEach(addValue); } if (typeof payload.urls_text === 'string') { payload.urls_text.split(/\r?\n|,/).forEach(addValue); } const inputCount = collected.length; if (!inputCount) { return res.status(400).json({ error: 'Keine URLs zum Import übergeben' }); } const normalizedTemplates = collected .map((raw) => normalizeDailyBookmarkUrlTemplate(raw)) .filter(Boolean); const invalidCount = inputCount - normalizedTemplates.length; const uniqueTemplates = [...new Set(normalizedTemplates)]; const duplicateCount = normalizedTemplates.length - uniqueTemplates.length; const createdItems = []; let skippedExisting = 0; const insertMany = db.transaction((templates) => { for (const template of templates) { if (findDailyBookmarkByUrlStmt.get(template)) { skippedExisting += 1; continue; } const id = uuidv4(); const title = normalizeDailyBookmarkTitle('', template); insertDailyBookmarkStmt.run({ id, title, url_template: template, notes: '', marker: normalizedMarker, is_active: normalizedActive }); const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey }); if (saved) { createdItems.push(saved); } } }); try { insertMany(uniqueTemplates); res.json({ created: createdItems.length, skipped_existing: skippedExisting, skipped_invalid: invalidCount, skipped_duplicates: duplicateCount, marker: normalizedMarker, day_key: dayKey, items: createdItems.map((row) => serializeDailyBookmark(row, dayKey)) }); } catch (error) { console.error('Failed to import daily bookmarks:', error); res.status(500).json({ error: 'Import fehlgeschlagen' }); } }); app.put('/api/daily-bookmarks/:bookmarkId', (req, res) => { const { bookmarkId } = req.params; if (!bookmarkId) { return res.status(400).json({ error: 'Bookmark-ID fehlt' }); } const payload = req.body || {}; const rawDay = (payload && payload.day) || (req.query && req.query.day); const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); if (!dayKey) { return res.status(400).json({ error: 'Ungültiges Tagesformat' }); } const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); if (!existing) { return res.status(404).json({ error: 'Bookmark nicht gefunden' }); } const normalizedUrl = normalizeDailyBookmarkUrlTemplate( payload.url_template ?? payload.url ?? existing.url_template ); const normalizedTitle = normalizeDailyBookmarkTitle( payload.title ?? payload.label ?? existing.title, normalizedUrl || existing.url_template ); const normalizedNotes = normalizeDailyBookmarkNotes( payload.notes ?? existing.notes ?? '' ); const normalizedMarker = normalizeDailyBookmarkMarker( payload.marker ?? existing.marker ?? '' ); const normalizedActive = normalizeDailyBookmarkActive( payload.is_active ?? payload.active ?? Number(existing.is_active ?? 1) ); if (!normalizedUrl) { return res.status(400).json({ error: 'URL-Template ist erforderlich' }); } const otherWithUrl = findOtherDailyBookmarkByUrlStmt.get({ url: normalizedUrl, id: bookmarkId }); if (otherWithUrl) { return res.status(409).json({ error: 'Bookmark mit dieser URL existiert bereits' }); } try { updateDailyBookmarkStmt.run({ id: bookmarkId, title: normalizedTitle, url_template: normalizedUrl, notes: normalizedNotes, marker: normalizedMarker, is_active: normalizedActive }); const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); res.json(serializeDailyBookmark(updated, dayKey)); } catch (error) { console.error('Failed to update daily bookmark:', error); res.status(500).json({ error: 'Daily Bookmark konnte nicht aktualisiert werden' }); } }); app.delete('/api/daily-bookmarks/:bookmarkId', (req, res) => { const { bookmarkId } = req.params; if (!bookmarkId) { return res.status(400).json({ error: 'Bookmark-ID fehlt' }); } try { const result = deleteDailyBookmarkStmt.run(bookmarkId); if (!result.changes) { return res.status(404).json({ error: 'Bookmark nicht gefunden' }); } res.status(204).send(); } catch (error) { console.error('Failed to delete daily bookmark:', error); res.status(500).json({ error: 'Daily Bookmark konnte nicht gelöscht werden' }); } }); app.post('/api/daily-bookmarks/:bookmarkId/check', (req, res) => { const { bookmarkId } = req.params; if (!bookmarkId) { return res.status(400).json({ error: 'Bookmark-ID fehlt' }); } const rawDay = (req.body && req.body.day) || (req.query && req.query.day); const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); if (!dayKey) { return res.status(400).json({ error: 'Ungültiges Tagesformat' }); } try { const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); if (!existing) { return res.status(404).json({ error: 'Bookmark nicht gefunden' }); } if (Number(existing.is_active ?? 1) === 0) { return res.status(400).json({ error: 'Bookmark ist deaktiviert' }); } upsertDailyBookmarkCheckStmt.run({ bookmarkId, dayKey }); const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); res.json(serializeDailyBookmark(updated, dayKey)); } catch (error) { console.error('Failed to complete daily bookmark:', error); res.status(500).json({ error: 'Daily Bookmark konnte nicht abgehakt werden' }); } }); app.delete('/api/daily-bookmarks/:bookmarkId/check', (req, res) => { const { bookmarkId } = req.params; if (!bookmarkId) { return res.status(400).json({ error: 'Bookmark-ID fehlt' }); } const rawDay = (req.body && req.body.day) || (req.query && req.query.day); const dayKey = rawDay ? normalizeDayKeyValue(rawDay) : resolveDayKey(rawDay); if (!dayKey) { return res.status(400).json({ error: 'Ungültiges Tagesformat' }); } try { const existing = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); if (!existing) { return res.status(404).json({ error: 'Bookmark nicht gefunden' }); } if (Number(existing.is_active ?? 1) === 0) { return res.status(400).json({ error: 'Bookmark ist deaktiviert' }); } deleteDailyBookmarkCheckStmt.run({ bookmarkId, dayKey }); const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey }); res.json(serializeDailyBookmark(updated, dayKey)); } catch (error) { console.error('Failed to undo daily bookmark completion:', error); res.status(500).json({ error: 'Daily Bookmark konnte nicht zurückgesetzt werden' }); } }); // Get all posts app.get('/api/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } else { res.write('\n'); } res.write(`retry: ${SSE_RETRY_INTERVAL_MS}\n\n`); const client = addSseClient(res); const initialPayload = { type: 'connected', clientId: client.id }; res.write(`data: ${JSON.stringify(initialPayload)}\n\n`); const cleanup = () => { removeSseClient(client.id); }; req.on('close', cleanup); res.on('close', cleanup); }); app.get('/api/posts', (req, res) => { try { const posts = db.prepare(` SELECT * FROM posts ORDER BY created_at DESC `).all(); res.json(posts.map(mapPostRow)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get post by URL app.get('/api/posts/by-url', (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL parameter required' }); } const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'URL parameter must be a valid Facebook link' }); } const post = findPostByUrl(normalizedUrl); if (!post) { return res.json(null); } const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]); if (alternates.length) { storePostUrls(post.id, post.url, alternates); touchPost(post.id, 'alternate-urls'); } res.json(mapPostRow(post)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/search-posts', (req, res) => { try { const { url, candidates, skip_increment, force_hide, sports_auto_hide } = req.body || {}; const normalizedUrls = collectNormalizedFacebookUrls(url, candidates); if (!normalizedUrls.length) { return res.status(400).json({ error: 'url must be a valid Facebook link' }); } cleanupExpiredSearchPosts(); let trackedPost = null; for (const candidate of normalizedUrls) { const found = findPostByUrl(candidate); if (found) { trackedPost = found; break; } } if (trackedPost) { const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls); storePostUrls(trackedPost.id, trackedPost.url, alternateUrls); removeSearchSeenEntries([trackedPost.url, ...alternateUrls]); if (alternateUrls.length) { touchPost(trackedPost.id, 'alternate-urls'); } return res.json({ seen_count: 0, should_hide: false, tracked: true }); } let existingRow = null; let existingUrl = null; for (const candidate of normalizedUrls) { const row = selectSearchSeenStmt.get(candidate); if (row) { existingRow = row; existingUrl = candidate; break; } } const targetUrl = existingUrl || normalizedUrls[0]; const existingManualHidden = existingRow ? !!existingRow.manually_hidden : false; const existingSportsHidden = existingRow ? !!existingRow.sports_auto_hidden : false; const sportsHideRequested = !!sports_auto_hide; if (force_hide) { const desiredCount = Math.max(existingRow ? existingRow.seen_count : 0, SEARCH_POST_HIDE_THRESHOLD); const urlsToUpdate = Array.from(new Set(normalizedUrls)); for (const candidate of urlsToUpdate) { const row = selectSearchSeenStmt.get(candidate); const candidateCount = row ? Math.max(row.seen_count, desiredCount) : desiredCount; const nextSportsHidden = sportsHideRequested || (row ? !!row.sports_auto_hidden : false); if (row) { updateSearchSeenStmt.run(candidateCount, 1, nextSportsHidden ? 1 : 0, candidate); } else { insertSearchSeenStmt.run(candidate, candidateCount, 1, nextSportsHidden ? 1 : 0); } } return res.json({ seen_count: desiredCount, should_hide: true, manually_hidden: true, sports_auto_hidden: sportsHideRequested || existingSportsHidden }); } if (skip_increment) { if (!existingRow) { return res.json({ seen_count: 0, should_hide: false, manually_hidden: false, sports_auto_hidden: false }); } const seenCount = existingRow.seen_count; const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || existingManualHidden || existingSportsHidden; return res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: existingManualHidden, sports_auto_hidden: existingSportsHidden }); } let seenCount = existingRow ? existingRow.seen_count + 1 : 1; const manualHidden = existingManualHidden; const sportsHidden = sportsHideRequested || existingSportsHidden; if (existingRow) { updateSearchSeenStmt.run(seenCount, manualHidden ? 1 : 0, sportsHidden ? 1 : 0, targetUrl); } else { insertSearchSeenStmt.run(targetUrl, seenCount, manualHidden ? 1 : 0, sportsHidden ? 1 : 0); } const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || manualHidden || sportsHidden; res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: manualHidden, sports_auto_hidden: sportsHidden }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.delete('/api/search-posts', (req, res) => { try { db.prepare('DELETE FROM search_seen_posts').run(); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/profile-state', (req, res) => { try { const scopeId = req.profileScope; let profileNumber = getScopedProfileNumber(scopeId); if (!profileNumber) { profileNumber = 1; setScopedProfileNumber(scopeId, profileNumber); } res.json({ profile_number: profileNumber }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/profile-state', (req, res) => { try { const { profile_number } = req.body; if (typeof profile_number === 'undefined') { return res.status(400).json({ error: 'profile_number is required' }); } const parsed = parseInt(profile_number, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > 5) { return res.status(400).json({ error: 'profile_number must be between 1 and 5' }); } const scopeId = req.profileScope; const sanitized = sanitizeProfileNumber(parsed) || 1; setScopedProfileNumber(scopeId, sanitized); res.json({ profile_number: sanitized }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/posts/:postId/screenshot', (req, res) => { try { const { postId } = req.params; const { imageData } = req.body; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (!imageData || typeof imageData !== 'string') { return res.status(400).json({ error: 'imageData is required' }); } const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/); if (!match) { return res.status(400).json({ error: 'Invalid image data format' }); } const mimeType = match[1]; const base64 = match[2]; const extension = mimeType === 'image/jpeg' ? 'jpg' : 'png'; const buffer = Buffer.from(base64, 'base64'); const fileName = `${postId}.${extension}`; const filePath = path.join(screenshotDir, fileName); if (post.screenshot_path && post.screenshot_path !== fileName) { const existingPath = path.join(screenshotDir, post.screenshot_path); if (fs.existsSync(existingPath)) { try { fs.unlinkSync(existingPath); } catch (error) { console.warn('Failed to remove previous screenshot:', error.message); } } } fs.writeFileSync(filePath, buffer); db.prepare('UPDATE posts SET screenshot_path = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(fileName, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const formattedPost = mapPostRow(updatedPost); res.json(formattedPost); broadcastPostChange(formattedPost, { reason: 'screenshot-updated' }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/posts/:postId/screenshot', (req, res) => { try { const { postId } = req.params; const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); // Placeholder path (mounted in Docker container) const placeholderPath = path.join(__dirname, 'noScreenshot.png'); if (!post || !post.screenshot_path) { // Return placeholder image if (fs.existsSync(placeholderPath)) { res.set('Cache-Control', 'no-store'); return res.sendFile(placeholderPath); } res.set('Cache-Control', 'no-store'); return res.status(404).json({ error: 'Screenshot not found' }); } const filePath = path.join(screenshotDir, post.screenshot_path); if (!fs.existsSync(filePath)) { // Return placeholder image if (fs.existsSync(placeholderPath)) { res.set('Cache-Control', 'no-store'); return res.sendFile(placeholderPath); } res.set('Cache-Control', 'no-store'); return res.status(404).json({ error: 'Screenshot not found' }); } res.set('Cache-Control', 'no-store'); res.sendFile(filePath); } catch (error) { res.status(500).json({ error: error.message }); } }); // Create new post app.post('/api/posts', (req, res) => { try { const { url, title, target_count, created_by_profile, created_by_name, profile_number, deadline_at, post_text } = req.body; const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); const alternateUrlsInput = Array.isArray(req.body.alternate_urls) ? req.body.alternate_urls : []; const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'URL must be a valid Facebook link' }); } if (!validatedTargetCount) { return res.status(400).json({ error: 'target_count must be between 1 and 5' }); } const id = uuidv4(); const normalizedPostText = normalizePostText(post_text); const postTextHash = computePostTextHash(normalizedPostText); const contentKey = extractFacebookContentKey(normalizedUrl); const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash; if (useTextHashDedup) { let existingByHash = selectPostByTextHashStmt.get(postTextHash); if (existingByHash) { const alternateCandidates = [normalizedUrl, ...alternateUrlsInput]; const alternateUrls = collectPostAlternateUrls(existingByHash.url, alternateCandidates); storePostUrls(existingByHash.id, existingByHash.url, alternateUrls); const cleanupSet = new Set([existingByHash.url, normalizedUrl, ...alternateUrls]); removeSearchSeenEntries(Array.from(cleanupSet)); if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) { updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id); touchPost(existingByHash.id, 'post-text-normalized'); existingByHash = db.prepare('SELECT * FROM posts WHERE id = ?').get(existingByHash.id); } return res.json(mapPostRow(existingByHash)); } } let creatorProfile = sanitizeProfileNumber(created_by_profile); if (!creatorProfile) { creatorProfile = sanitizeProfileNumber(profile_number) || null; } let normalizedDeadline = null; if (typeof deadline_at !== 'undefined' && deadline_at !== null && String(deadline_at).trim() !== '') { normalizedDeadline = normalizeDeadline(deadline_at); if (!normalizedDeadline) { return res.status(400).json({ error: 'deadline_at must be a valid date string' }); } } const creatorDisplayName = normalizeCreatorName(created_by_name); const stmt = db.prepare(` INSERT INTO posts ( id, url, title, target_count, checked_count, screenshot_path, created_by_profile, created_by_name, deadline_at, post_text, post_text_hash, content_key, last_change ) VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); stmt.run( id, normalizedUrl, title || '', validatedTargetCount, creatorProfile, creatorDisplayName, normalizedDeadline, normalizedPostText, postTextHash, contentKey || null ); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateUrlsInput); storePostUrls(id, normalizedUrl, alternateUrls); removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); const formattedPost = mapPostRow(post); res.json(formattedPost); broadcastPostChange(formattedPost, { reason: 'created' }); } catch (error) { if (error.message.includes('UNIQUE constraint failed')) { res.status(409).json({ error: 'Post with this URL already exists' }); } else { res.status(500).json({ error: error.message }); } } }); app.put('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const { target_count, title, created_by_profile, created_by_name, deadline_at, url, post_text } = req.body || {}; const alternateUrlsInput = Array.isArray(req.body && req.body.alternate_urls) ? req.body.alternate_urls : []; const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!existingPost) { return res.status(404).json({ error: 'Post not found' }); } const updates = []; const params = []; let normalizedUrlForCleanup = null; let updatedContentKey = null; if (typeof target_count !== 'undefined') { const validatedTargetCount = validateTargetCount(target_count); if (!validatedTargetCount) { return res.status(400).json({ error: 'target_count must be between 1 and 5' }); } updates.push('target_count = ?'); params.push(validatedTargetCount); } if (typeof title !== 'undefined') { updates.push('title = ?'); params.push(title || ''); } if (typeof created_by_profile !== 'undefined') { const sanitized = sanitizeProfileNumber(created_by_profile); if (created_by_profile !== null && typeof created_by_profile !== 'undefined' && !sanitized) { return res.status(400).json({ error: 'created_by_profile must be between 1 and 5 or null' }); } updates.push('created_by_profile = ?'); params.push(sanitized || null); } if (typeof created_by_name !== 'undefined') { const normalizedName = normalizeCreatorName(created_by_name); updates.push('created_by_name = ?'); params.push(normalizedName); } if (typeof deadline_at !== 'undefined') { let normalizedDeadline = null; const rawDeadline = deadline_at; if (rawDeadline !== null && String(rawDeadline).trim() !== '') { normalizedDeadline = normalizeDeadline(rawDeadline); if (!normalizedDeadline) { return res.status(400).json({ error: 'deadline_at must be a valid date string' }); } } updates.push('deadline_at = ?'); params.push(normalizedDeadline); } if (typeof url !== 'undefined') { const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'url must be a valid Facebook link' }); } updates.push('url = ?'); params.push(normalizedUrl); normalizedUrlForCleanup = normalizedUrl; const newContentKey = extractFacebookContentKey(normalizedUrl); updates.push('content_key = ?'); params.push(newContentKey || null); updatedContentKey = newContentKey || null; } if (typeof post_text !== 'undefined') { const normalizedPostText = normalizePostText(post_text); const postTextHash = computePostTextHash(normalizedPostText); updates.push('post_text = ?'); params.push(normalizedPostText); updates.push('post_text_hash = ?'); params.push(postTextHash); } if (!updates.length) { return res.status(400).json({ error: 'No valid fields to update' }); } updates.push('last_change = CURRENT_TIMESTAMP'); params.push(postId); const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); try { stmt.run(...params); } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { return res.status(409).json({ error: 'Post with this URL already exists' }); } throw error; } recalcCheckedCount(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const alternateCandidates = [...alternateUrlsInput]; if (existingPost.url && existingPost.url !== updatedPost.url) { alternateCandidates.push(existingPost.url); } const alternateUrls = collectPostAlternateUrls(updatedPost.url, alternateCandidates); storePostUrls(updatedPost.id, updatedPost.url, alternateUrls); const cleanupUrls = new Set([updatedPost.url]); alternateUrls.forEach(urlValue => cleanupUrls.add(urlValue)); if (normalizedUrlForCleanup && normalizedUrlForCleanup !== updatedPost.url) { cleanupUrls.add(normalizedUrlForCleanup); } removeSearchSeenEntries(Array.from(cleanupUrls)); const formattedPost = mapPostRow(updatedPost); res.json(formattedPost); broadcastPostChange(formattedPost, { reason: 'updated' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Check a post for a profile app.post('/api/posts/:postId/check', (req, res) => { try { const { postId } = req.params; const { profile_number } = req.body; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Check if deadline has passed if (post.deadline_at) { const deadline = new Date(post.deadline_at); if (new Date() > deadline) { return res.status(400).json({ error: 'Deadline ist abgelaufen' }); } } const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, postId); post.target_count = requiredProfiles.length; didChange = true; } let profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { const storedProfile = getScopedProfileNumber(req.profileScope); profileValue = storedProfile || requiredProfiles[0]; } if (!requiredProfiles.includes(profileValue)) { return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); } const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' ).get(postId, profileValue); if (existingCheck) { const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); return res.json(mapPostRow(existingPost)); } // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; if (requiredProfiles.length > 0 && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); const completedSet = new Set( completedRows .map(row => sanitizeProfileNumber(row.profile_number)) .filter(Boolean) ); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ error: 'Vorherige Profile müssen zuerst bestätigen.', missing_profiles: missingPrerequisites }); } } } const insertStmt = db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)'); insertStmt.run(postId, profileValue); didChange = true; recalcCheckedCount(postId); if (didChange) { touchPost(postId, 'profile-status-update'); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/posts/:postId/urls', (req, res) => { try { const { postId } = req.params; const { urls } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } const candidateList = Array.isArray(urls) ? urls : []; const alternateUrls = collectPostAlternateUrls(post.url, candidateList); storePostUrls(post.id, post.url, alternateUrls); removeSearchSeenEntries([post.url, ...alternateUrls]); const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); if (alternateUrls.length) { touchPost(post.id, 'alternate-urls'); } res.json({ success: true, primary_url: post.url, alternate_urls: storedAlternates }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Check by URL (for web interface auto-check) app.post('/api/check-by-url', (req, res) => { try { const { url, profile_number } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'URL must be a valid Facebook link' }); } const post = findPostByUrl(normalizedUrl); if (!post) { return res.status(404).json({ error: 'Post not found' }); } const alternateUrls = collectPostAlternateUrls(post.url, [normalizedUrl]); storePostUrls(post.id, post.url, alternateUrls); removeSearchSeenEntries([post.url, ...alternateUrls]); // Check if deadline has passed if (post.deadline_at) { const deadline = new Date(post.deadline_at); if (new Date() > deadline) { return res.status(400).json({ error: 'Deadline ist abgelaufen' }); } } const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id); post.target_count = requiredProfiles.length; didChange = true; } let profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { const storedProfile = getScopedProfileNumber(req.profileScope); profileValue = storedProfile || requiredProfiles[0]; } if (!requiredProfiles.includes(profileValue)) { return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); } const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' ).get(post.id, profileValue); if (existingCheck) { const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); return res.json(mapPostRow(updatedPost)); } // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; if (requiredProfiles.length > 0 && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id); const completedSet = new Set( completedRows .map(row => sanitizeProfileNumber(row.profile_number)) .filter(Boolean) ); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ error: 'Vorherige Profile müssen zuerst bestätigen.', missing_profiles: missingPrerequisites }); } } } db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(post.id, profileValue); didChange = true; recalcCheckedCount(post.id); if (didChange) { touchPost(post.id, 'check-by-url'); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/posts/:postId/profile-status', (req, res) => { try { const { postId } = req.params; const { profile_number, status } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Check if deadline has passed (only for setting to 'done') if (status === 'done' && post.deadline_at) { const deadline = new Date(post.deadline_at); if (new Date() > deadline) { return res.status(400).json({ error: 'Deadline ist abgelaufen' }); } } const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id); post.target_count = requiredProfiles.length; didChange = true; } const profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { return res.status(400).json({ error: 'Valid profile_number required' }); } if (!requiredProfiles.includes(profileValue)) { return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); } const normalizedStatus = status === 'done' ? 'done' : 'pending'; if (normalizedStatus === 'done') { // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; if (!isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); const completedSet = new Set( completedRows .map(row => sanitizeProfileNumber(row.profile_number)) .filter(Boolean) ); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ error: 'Vorherige Profile müssen zuerst bestätigen.', missing_profiles: missingPrerequisites }); } } } const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' ).get(postId, profileValue); if (!existingCheck) { db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(postId, profileValue); didChange = true; } } else { db.prepare('DELETE FROM checks WHERE post_id = ? AND profile_number = ?').run(postId, profileValue); didChange = true; } recalcCheckedCount(postId); if (didChange) { touchPost(postId, 'profile-status-update'); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update post URL or success status app.patch('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const { url, is_successful } = req.body; // Check if post exists const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!existingPost) { return res.status(404).json({ error: 'Post not found' }); } if (url !== undefined) { const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'Invalid Facebook URL' }); } // Check for URL conflicts const conflict = db.prepare('SELECT id FROM posts WHERE url = ? AND id != ?').get(normalizedUrl, postId); if (conflict) { return res.status(409).json({ error: 'URL already used by another post' }); } const contentKey = extractFacebookContentKey(normalizedUrl); // Update URL db.prepare('UPDATE posts SET url = ?, content_key = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(normalizedUrl, contentKey || null, postId); const alternateCandidates = []; if (existingPost.url && existingPost.url !== normalizedUrl) { alternateCandidates.push(existingPost.url); } const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates); storePostUrls(postId, normalizedUrl, alternateUrls); removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); queuePostBroadcast(postId, { reason: 'url-updated' }); return res.json({ success: true, url: normalizedUrl }); } if (is_successful !== undefined) { const successValue = is_successful ? 1 : 0; db.prepare('UPDATE posts SET is_successful = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(successValue, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const formattedPost = mapPostRow(updatedPost); res.json(formattedPost); broadcastPostChange(formattedPost, { reason: 'success-flag' }); return; } return res.status(400).json({ error: 'No valid update parameter provided' }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/posts/merge', (req, res) => { const { primary_post_id, secondary_post_id, primary_url } = req.body || {}; if (!primary_post_id || !secondary_post_id) { return res.status(400).json({ error: 'primary_post_id und secondary_post_id sind erforderlich' }); } if (primary_post_id === secondary_post_id) { return res.status(400).json({ error: 'Die Post-IDs müssen unterschiedlich sein' }); } try { const primaryPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(primary_post_id); const secondaryPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(secondary_post_id); if (!primaryPost || !secondaryPost) { return res.status(404).json({ error: 'Einer der Beiträge wurde nicht gefunden' }); } const normalizedPrimaryUrl = primary_url ? normalizeFacebookPostUrl(primary_url) : normalizeFacebookPostUrl(primaryPost.url); if (!normalizedPrimaryUrl) { return res.status(400).json({ error: 'primary_url ist ungültig' }); } const conflictId = findPostIdByUrl(normalizedPrimaryUrl); if (conflictId && conflictId !== primary_post_id && conflictId !== secondary_post_id) { return res.status(409).json({ error: 'Die gewählte Haupt-URL gehört bereits zu einem anderen Beitrag' }); } const collectUrlsForPost = (post) => { const urls = []; if (post && post.url) { urls.push(post.url); } if (post && post.id) { const alternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); urls.push(...alternates); } return urls; }; const mergedUrlSet = new Set(); for (const url of [...collectUrlsForPost(primaryPost), ...collectUrlsForPost(secondaryPost)]) { const normalized = normalizeFacebookPostUrl(url); if (normalized) { mergedUrlSet.add(normalized); } } mergedUrlSet.add(normalizedPrimaryUrl); const alternateUrls = Array.from(mergedUrlSet).filter(url => url !== normalizedPrimaryUrl); const mergedDeadline = (() => { if (primaryPost.deadline_at && secondaryPost.deadline_at) { return new Date(primaryPost.deadline_at) <= new Date(secondaryPost.deadline_at) ? primaryPost.deadline_at : secondaryPost.deadline_at; } return primaryPost.deadline_at || secondaryPost.deadline_at || null; })(); const mergedTargetCount = Math.max( validateTargetCount(primaryPost.target_count) || 1, validateTargetCount(secondaryPost.target_count) || 1 ); const mergedPostText = (primaryPost.post_text && primaryPost.post_text.trim()) ? primaryPost.post_text : (secondaryPost.post_text || null); const normalizedMergedPostText = mergedPostText ? normalizePostText(mergedPostText) : null; const mergedPostTextHash = normalizedMergedPostText ? computePostTextHash(normalizedMergedPostText) : null; const mergedCreatorName = (primaryPost.created_by_name && primaryPost.created_by_name.trim()) ? primaryPost.created_by_name : (secondaryPost.created_by_name || null); const mergedCreatorProfile = primaryPost.created_by_profile || secondaryPost.created_by_profile || null; const mergedTitle = (primaryPost.title && primaryPost.title.trim()) ? primaryPost.title : (secondaryPost.title || null); const mergeTransaction = db.transaction(() => { // Move checks from secondary to primary (one per profile) const primaryChecks = selectChecksForPostStmt.all(primary_post_id); const secondaryChecks = selectChecksForPostStmt.all(secondary_post_id); const primaryByProfile = new Map(); for (const check of primaryChecks) { if (!check || !check.profile_number) { continue; } const existing = primaryByProfile.get(check.profile_number); if (!existing) { primaryByProfile.set(check.profile_number, check); } else { if (new Date(check.checked_at) < new Date(existing.checked_at)) { primaryByProfile.set(check.profile_number, check); } else { deleteCheckByIdStmt.run(check.id); } } } for (const check of secondaryChecks) { if (!check || !check.profile_number) { continue; } const existing = primaryByProfile.get(check.profile_number); if (!existing) { updateCheckPostStmt.run(primary_post_id, check.id); primaryByProfile.set(check.profile_number, check); } else { const existingDate = new Date(existing.checked_at); const candidateDate = new Date(check.checked_at); if (candidateDate < existingDate) { updateCheckTimestampStmt.run(check.checked_at, existing.id); } deleteCheckByIdStmt.run(check.id); } } // Delete secondary post (post_urls are cascaded) db.prepare('DELETE FROM posts WHERE id = ?').run(secondary_post_id); const primaryContentKey = extractFacebookContentKey(normalizedPrimaryUrl); db.prepare(` UPDATE posts SET url = ?, content_key = ?, target_count = ?, created_by_name = ?, created_by_profile = ?, deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, last_change = CURRENT_TIMESTAMP WHERE id = ? `).run( normalizedPrimaryUrl, primaryContentKey || null, mergedTargetCount, mergedCreatorName, mergedCreatorProfile, mergedDeadline, mergedTitle, normalizedMergedPostText, mergedPostTextHash, primary_post_id ); storePostUrls(primary_post_id, normalizedPrimaryUrl, alternateUrls, { skipContentKeyCheck: true }); recalcCheckedCount(primary_post_id); const updated = db.prepare('SELECT * FROM posts WHERE id = ?').get(primary_post_id); return updated; }); const merged = mergeTransaction(); if (secondaryPost && secondaryPost.screenshot_path) { const secondaryFile = path.join(screenshotDir, secondaryPost.screenshot_path); if (fs.existsSync(secondaryFile)) { try { fs.unlinkSync(secondaryFile); } catch (cleanupError) { console.warn('Konnte Screenshot des zusammengeführten Beitrags nicht entfernen:', cleanupError.message); } } } const mapped = mapPostRow(merged); broadcastPostChange(mapped, { reason: 'merged' }); broadcastPostDeletion(secondary_post_id, { reason: 'merged' }); res.json(mapped); } catch (error) { console.error('Merge failed:', error); res.status(500).json({ error: 'Beiträge konnten nicht gemerged werden' }); } }); // Delete post app.delete('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); db.prepare('DELETE FROM checks WHERE post_id = ?').run(postId); const result = db.prepare('DELETE FROM posts WHERE id = ?').run(postId); if (result.changes === 0) { return res.status(404).json({ error: 'Post not found' }); } if (post && post.screenshot_path) { const filePath = path.join(screenshotDir, post.screenshot_path); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (error) { console.warn('Failed to remove screenshot:', error.message); } } } res.json({ success: true }); broadcastPostDeletion(postId, { reason: 'deleted' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // AI Credentials endpoints app.get('/api/ai-credentials', (req, res) => { try { reactivateExpiredCredentials(); const credentials = getAllCredentialsFormatted(); res.json(credentials); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/ai-credentials', (req, res) => { try { const { name, provider, api_key, model, base_url } = req.body; const trimmedName = typeof name === 'string' ? name.trim() : ''; const trimmedProvider = typeof provider === 'string' ? provider.trim() : ''; const trimmedApiKey = typeof api_key === 'string' ? api_key.trim() : ''; const trimmedModel = typeof model === 'string' ? model.trim() : ''; const rawBaseUrl = typeof base_url === 'string' ? base_url.trim() : ''; const normalizedBaseUrl = (trimmedProvider === 'openai' && rawBaseUrl) ? rawBaseUrl.replace(/\/+$/, '') : ''; if (!trimmedName || !trimmedProvider) { return res.status(400).json({ error: 'Name und Provider sind erforderlich' }); } if (normalizedBaseUrl && !/^https?:\/\//i.test(normalizedBaseUrl)) { return res.status(400).json({ error: 'Basis-URL muss mit http:// oder https:// beginnen' }); } let finalApiKey = trimmedApiKey; if (!finalApiKey) { if (trimmedProvider === 'openai' && normalizedBaseUrl) { finalApiKey = ''; } else { return res.status(400).json({ error: 'API-Schlüssel wird benötigt' }); } } const result = db.prepare(` INSERT INTO ai_credentials (name, provider, api_key, model, base_url) VALUES (?, ?, ?, ?, ?) `).run( trimmedName, trimmedProvider, finalApiKey, trimmedModel || null, normalizedBaseUrl || null ); const credential = getFormattedCredentialById(result.lastInsertRowid); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/ai-credentials/:id', (req, res) => { try { const { id } = req.params; const { name, provider, api_key, model, base_url } = req.body; const credentialId = parseInt(id, 10); const existing = db.prepare('SELECT * FROM ai_credentials WHERE id = ?').get(credentialId); if (!existing) { return res.status(404).json({ error: 'Credential nicht gefunden' }); } const trimmedName = typeof name === 'string' ? name.trim() : existing.name; const trimmedProvider = typeof provider === 'string' ? provider.trim() : existing.provider; if (!trimmedName || !trimmedProvider) { return res.status(400).json({ error: 'Name und Provider sind erforderlich' }); } const trimmedModel = typeof model === 'string' ? model.trim() : existing.model || ''; const rawBaseUrl = typeof base_url === 'string' ? base_url.trim() : (existing.base_url || ''); const normalizedBaseUrl = (trimmedProvider === 'openai' && rawBaseUrl) ? rawBaseUrl.replace(/\/+$/, '') : ''; let apiKeyProvided = api_key !== undefined; let trimmedApiKey = typeof api_key === 'string' ? api_key.trim() : ''; if (!apiKeyProvided) { trimmedApiKey = existing.api_key; } else if (!trimmedApiKey && !(trimmedProvider === 'openai' && normalizedBaseUrl)) { return res.status(400).json({ error: 'API-Schlüssel wird benötigt' }); } if (normalizedBaseUrl && !/^https?:\/\//i.test(normalizedBaseUrl)) { return res.status(400).json({ error: 'Basis-URL muss mit http:// oder https:// beginnen' }); } if (trimmedProvider === 'openai' && !trimmedApiKey && !normalizedBaseUrl) { return res.status(400).json({ error: 'Für OpenAI wird ein API-Schlüssel benötigt, wenn keine Basis-URL angegeben ist' }); } db.prepare(` UPDATE ai_credentials SET name = ?, provider = ?, api_key = ?, model = ?, base_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run( trimmedName, trimmedProvider, trimmedApiKey, trimmedModel || null, normalizedBaseUrl || null, credentialId ); const credential = getFormattedCredentialById(credentialId); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); } }); app.patch('/api/ai-credentials/:id', (req, res) => { try { const { id } = req.params; const { is_active } = req.body; if (is_active === undefined) { return res.status(400).json({ error: 'is_active is required' }); } const isActiveInt = is_active ? 1 : 0; db.prepare(` UPDATE ai_credentials SET is_active = ?, updated_at = CURRENT_TIMESTAMP, auto_disabled = CASE WHEN ? = 1 THEN 0 ELSE auto_disabled END, auto_disabled_reason = CASE WHEN ? = 1 THEN NULL ELSE auto_disabled_reason END, auto_disabled_until = CASE WHEN ? = 1 THEN NULL ELSE auto_disabled_until END WHERE id = ? `).run(isActiveInt, isActiveInt, isActiveInt, isActiveInt, id); const credential = getFormattedCredentialById(id); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/ai-credentials/reorder', (req, res) => { try { const { order } = req.body; // Array of IDs in new order if (!Array.isArray(order)) { return res.status(400).json({ error: 'order must be an array' }); } // Update priorities based on order order.forEach((id, index) => { db.prepare('UPDATE ai_credentials SET priority = ? WHERE id = ?').run(index, id); }); const credentials = getAllCredentialsFormatted(); res.json(credentials); } catch (error) { res.status(500).json({ error: error.message }); } }); app.delete('/api/ai-credentials/:id', (req, res) => { try { const { id } = req.params; // Check if this credential is active const settings = db.prepare('SELECT active_credential_id FROM ai_settings WHERE id = 1').get(); if (settings && settings.active_credential_id === parseInt(id)) { return res.status(400).json({ error: 'Cannot delete active credential' }); } db.prepare('DELETE FROM ai_credentials WHERE id = ?').run(id); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // AI Settings endpoints app.get('/api/ai-settings', (req, res) => { try { let settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); if (!settings) { settings = { id: 1, active_credential_id: null, prompt_prefix: 'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n', enabled: 0, updated_at: null }; } // Get active credential if set let activeCredential = null; if (settings.active_credential_id) { activeCredential = getFormattedCredentialById(settings.active_credential_id); } res.json({ ...settings, active_credential: activeCredential }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/ai-settings', (req, res) => { try { const { active_credential_id, prompt_prefix, enabled } = req.body; const existing = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); if (existing) { db.prepare(` UPDATE ai_settings SET active_credential_id = ?, prompt_prefix = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); } else { db.prepare(` INSERT INTO ai_settings (id, active_credential_id, prompt_prefix, enabled, updated_at) VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP) `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); } const updated = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); let activeCredential = null; if (updated.active_credential_id) { activeCredential = getFormattedCredentialById(updated.active_credential_id); } res.json({ ...updated, active_credential: activeCredential }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/moderation-settings', (req, res) => { try { const settings = loadModerationSettings(); res.json(settings); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/moderation-settings', (req, res) => { try { const body = req.body || {}; const saved = persistModerationSettings({ enabled: !!body.sports_scoring_enabled, threshold: body.sports_score_threshold, weights: body.sports_score_weights, terms: body.sports_terms, autoHide: !!body.sports_auto_hide_enabled }); res.json(saved); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/hidden-settings', (req, res) => { try { const settings = loadHiddenSettings(); res.json({ auto_purge_enabled: !!settings.auto_purge_hidden, retention_days: settings.search_retention_days }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/hidden-settings', (req, res) => { try { const body = req.body || {}; const retentionDays = normalizeRetentionDays(body.retention_days); const autoPurgeEnabled = !!body.auto_purge_enabled; const saved = persistHiddenSettings({ retentionDays, autoPurgeEnabled }); if (saved.auto_purge_enabled) { cleanupExpiredSearchPosts(); } res.json(saved); } catch (error) { res.status(500).json({ error: error.message }); } }); function sanitizeAIComment(text) { if (!text) { return ''; } let cleaned = text .replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, '') // strip zero-width/control spacing .replace(/\u00a0/g, ' '); // normalize NBSP // Strip leading label noise some models prepend (e.g. "**Kommentar**, **Inhalt**:") const markerPattern = /^(?:\s*(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[,;:.\-–—`'"]*\s*)+/i; cleaned = cleaned.replace(markerPattern, ''); // Clean up AI output: drop hidden tags, replace dashes, normalize spacing. return cleaned .replace(/[\s\S]*?<\/think>/gi, '') .replace(/[-–—]+/g, (match, offset, full) => { const prev = full[offset - 1]; const next = full[offset + match.length]; const prevIsWord = prev && /[A-Za-z0-9ÄÖÜäöüß]/.test(prev); const nextIsWord = next && /[A-Za-z0-9ÄÖÜäöüß]/.test(next); return prevIsWord && nextIsWord ? match : ', '; }) .replace(/^[\s,;:.\-–—!?\u00a0"'`]+/, '') .replace(/\s{2,}/g, ' ') .trim(); } async function tryGenerateComment(credential, promptPrefix, postText) { const provider = credential.provider; const apiKey = credential.api_key; const model = credential.model; let comment = ''; let lastResponse = null; try { if (provider === 'gemini') { const modelName = model || 'gemini-2.0-flash-exp'; const prompt = promptPrefix + postText; const response = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) } ); lastResponse = response; if (!response.ok) { let errorPayload = null; let message = response.statusText; try { errorPayload = await response.json(); message = errorPayload?.error?.message || message; } catch (jsonError) { try { const textBody = await response.text(); if (textBody) { message = textBody; } } catch (textError) { // ignore } } const rateInfo = extractRateLimitInfo(response, provider); const error = new Error(`Gemini API error: ${message}`); error.status = response.status; error.provider = provider; error.apiError = errorPayload; if (rateInfo.retryAfterSeconds !== undefined) { error.retryAfterSeconds = rateInfo.retryAfterSeconds; } if (rateInfo.rateLimitResetAt) { error.rateLimitResetAt = rateInfo.rateLimitResetAt; } if (rateInfo.rateLimitRemaining !== undefined) { error.rateLimitRemaining = rateInfo.rateLimitRemaining; } error.rateLimitHeaders = rateInfo.headers; throw error; } const data = await response.json(); comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else if (provider === 'openai') { const modelName = model || 'gpt-3.5-turbo'; const prompt = promptPrefix + postText; const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; const headers = { 'Content-Type': 'application/json' }; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ model: modelName, messages: [{ role: 'user', content: prompt }], max_tokens: 150 }) }); lastResponse = response; if (!response.ok) { let errorPayload = null; let message = response.statusText; try { errorPayload = await response.json(); message = errorPayload?.error?.message || message; } catch (jsonError) { try { const textBody = await response.text(); if (textBody) { message = textBody; } } catch (textError) { // ignore } } const rateInfo = extractRateLimitInfo(response, provider); const error = new Error(`OpenAI API error: ${message}`); error.status = response.status; error.provider = provider; error.apiError = errorPayload; if (rateInfo.retryAfterSeconds !== undefined) { error.retryAfterSeconds = rateInfo.retryAfterSeconds; } if (rateInfo.rateLimitResetAt) { error.rateLimitResetAt = rateInfo.rateLimitResetAt; } if (rateInfo.rateLimitRemaining !== undefined) { error.rateLimitRemaining = rateInfo.rateLimitRemaining; } error.rateLimitHeaders = rateInfo.headers; throw error; } const data = await response.json(); comment = data.choices?.[0]?.message?.content || ''; } else if (provider === 'claude') { const modelName = model || 'claude-3-5-haiku-20241022'; const prompt = promptPrefix + postText; const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: modelName, max_tokens: 150, messages: [{ role: 'user', content: prompt }] }) }); lastResponse = response; if (!response.ok) { let errorPayload = null; let message = response.statusText; try { errorPayload = await response.json(); message = errorPayload?.error?.message || message; } catch (jsonError) { try { const textBody = await response.text(); if (textBody) { message = textBody; } } catch (textError) { // ignore } } const rateInfo = extractRateLimitInfo(response, provider); const error = new Error(`Claude API error: ${message}`); error.status = response.status; error.provider = provider; error.apiError = errorPayload; if (rateInfo.retryAfterSeconds !== undefined) { error.retryAfterSeconds = rateInfo.retryAfterSeconds; } if (rateInfo.rateLimitResetAt) { error.rateLimitResetAt = rateInfo.rateLimitResetAt; } if (rateInfo.rateLimitRemaining !== undefined) { error.rateLimitRemaining = rateInfo.rateLimitRemaining; } error.rateLimitHeaders = rateInfo.headers; throw error; } const data = await response.json(); comment = data.content?.[0]?.text || ''; } else { throw new Error(`Unsupported AI provider: ${provider}`); } const rateInfo = extractRateLimitInfo(lastResponse, provider); rateInfo.status = lastResponse ? lastResponse.status : null; return { comment: sanitizeAIComment(comment), rateInfo }; } catch (error) { if (error && !error.provider) { error.provider = provider; } throw error; } } app.post('/api/ai/generate-comment', async (req, res) => { try { const { postText, profileNumber, preferredCredentialId } = req.body; if (!postText) { return res.status(400).json({ error: 'postText is required' }); } const settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); if (!settings || !settings.enabled) { return res.status(400).json({ error: 'AI comment generation is not enabled' }); } reactivateExpiredCredentials(); // Get all active credentials, ordered by priority const credentials = db.prepare(` SELECT * FROM ai_credentials WHERE is_active = 1 AND COALESCE(auto_disabled, 0) = 0 ORDER BY priority ASC, id ASC `).all(); if (!credentials || credentials.length === 0) { return res.status(400).json({ error: 'No active AI credentials available' }); } let orderedCredentials = credentials; if (typeof preferredCredentialId !== 'undefined' && preferredCredentialId !== null) { const parsedPreferredId = Number(preferredCredentialId); if (!Number.isNaN(parsedPreferredId)) { const idx = credentials.findIndex(credential => credential.id === parsedPreferredId); if (idx > 0) { const preferred = credentials[idx]; orderedCredentials = [preferred, ...credentials.slice(0, idx), ...credentials.slice(idx + 1)]; } } } let promptPrefix = settings.prompt_prefix || ''; // Get friend names for the profile if available if (profileNumber) { const friends = db.prepare('SELECT friend_names FROM profile_friends WHERE profile_number = ?').get(profileNumber); if (friends && friends.friend_names) { promptPrefix = promptPrefix.replace('{FREUNDE}', friends.friend_names); } else { promptPrefix = promptPrefix.replace('{FREUNDE}', ''); } } else { promptPrefix = promptPrefix.replace('{FREUNDE}', ''); } // Try each active credential until one succeeds let lastError = null; const attemptDetails = []; for (const credential of orderedCredentials) { try { console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`); const { comment, rateInfo } = await tryGenerateComment(credential, promptPrefix, postText); console.log(`Success with credential: ${credential.name}`); updateCredentialUsageOnSuccess(credential.id, rateInfo || {}); attemptDetails.push({ credentialId: credential.id, credentialName: credential.name, status: 'success', rateLimitRemaining: rateInfo?.rateLimitRemaining ?? null, rateLimitResetAt: rateInfo?.rateLimitResetAt ?? null }); return res.json({ comment, usedCredential: credential.name, usedCredentialId: credential.id, attempts: attemptDetails, rateLimitInfo: rateInfo || null }); } catch (error) { console.error(`Failed with credential ${credential.name}:`, error.message); lastError = error; const errorUpdate = updateCredentialUsageOnError(credential.id, error); attemptDetails.push({ credentialId: credential.id, credentialName: credential.name, status: 'error', message: error.message, statusCode: error.status || error.statusCode || null, autoDisabled: Boolean(errorUpdate.autoDisabled), autoDisabledUntil: errorUpdate.autoDisabledUntil || null }); // Continue to next credential } } // If we get here, all credentials failed const finalError = lastError || new Error('All AI credentials failed'); finalError.attempts = attemptDetails; throw finalError; } catch (error) { console.error('AI comment generation error:', error); if (error && error.attempts) { res.status(500).json({ error: error.message, attempts: error.attempts }); } else { res.status(500).json({ error: error.message }); } } }); // ============================================================================ // PROFILE FRIENDS API // ============================================================================ // Get friends for a profile app.get('/api/profile-friends/:profileNumber', (req, res) => { try { const profileNumber = parseInt(req.params.profileNumber); const friends = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); if (!friends) { return res.json({ profile_number: profileNumber, friend_names: '' }); } res.json(friends); } catch (error) { console.error('Error fetching profile friends:', error); res.status(500).json({ error: error.message }); } }); // Update friends for a profile app.put('/api/profile-friends/:profileNumber', (req, res) => { try { const profileNumber = parseInt(req.params.profileNumber); const { friend_names } = req.body; if (friend_names === undefined) { return res.status(400).json({ error: 'friend_names is required' }); } const existing = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); if (existing) { db.prepare('UPDATE profile_friends SET friend_names = ?, updated_at = CURRENT_TIMESTAMP WHERE profile_number = ?') .run(friend_names, profileNumber); } else { db.prepare('INSERT INTO profile_friends (profile_number, friend_names) VALUES (?, ?)') .run(profileNumber, friend_names); } const updated = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); res.json(updated); } catch (error) { console.error('Error updating profile friends:', error); res.status(500).json({ error: error.message }); } }); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); startAutomationWorker(); function logRuntimeInfo() { let osPretty = ''; try { const raw = fs.readFileSync('/etc/os-release', 'utf8'); const match = raw.match(/^PRETTY_NAME="?(.*?)"?$/m); if (match && match[1]) { osPretty = match[1]; } } catch (error) { // ignore } const osInfo = osPretty || `${os.platform()} ${os.release()}`; console.log(`Runtime: Node ${process.version}, OS ${osInfo}`); } app.listen(PORT, '0.0.0.0', () => { logRuntimeInfo(); console.log(`Server running on port ${PORT}`); });