6748 lines
207 KiB
JavaScript
6748 lines
207 KiB
JavaScript
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 MIN_SIMILAR_TEXT_LENGTH = 60;
|
|
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 SIMILARITY_DEFAULTS = {
|
|
text_threshold: 0.85,
|
|
image_distance_threshold: 6
|
|
};
|
|
|
|
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');
|
|
ensureColumn('posts', 'first_image_hash', 'first_image_hash TEXT');
|
|
ensureColumn('posts', 'first_image_url', 'first_image_url 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 tokenizeSimilarityText(text) {
|
|
if (!text) {
|
|
return [];
|
|
}
|
|
const tokens = text.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
|
|
return tokens.filter(token => token.length > 1);
|
|
}
|
|
|
|
function computeTextSimilarity(a, b) {
|
|
if (!a || !b) {
|
|
return 0;
|
|
}
|
|
const tokensA = tokenizeSimilarityText(a);
|
|
const tokensB = tokenizeSimilarityText(b);
|
|
if (!tokensA.length || !tokensB.length) {
|
|
return 0;
|
|
}
|
|
const setA = new Set(tokensA);
|
|
const setB = new Set(tokensB);
|
|
let intersection = 0;
|
|
for (const token of setA) {
|
|
if (setB.has(token)) {
|
|
intersection += 1;
|
|
}
|
|
}
|
|
const union = setA.size + setB.size - intersection;
|
|
if (union <= 0) {
|
|
return 0;
|
|
}
|
|
return intersection / union;
|
|
}
|
|
|
|
function hammingDistanceHex(a, b) {
|
|
if (!a || !b) {
|
|
return null;
|
|
}
|
|
const left = String(a).toLowerCase().replace(/^0x/, '');
|
|
const right = String(b).toLowerCase().replace(/^0x/, '');
|
|
if (left.length !== right.length) {
|
|
return null;
|
|
}
|
|
let xor;
|
|
try {
|
|
xor = BigInt(`0x${left}`) ^ BigInt(`0x${right}`);
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
let distance = 0;
|
|
while (xor > 0n) {
|
|
distance += Number(xor & 1n);
|
|
xor >>= 1n;
|
|
}
|
|
return distance;
|
|
}
|
|
|
|
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 buildCompletedProfileSet(rows) {
|
|
const completedSet = new Set();
|
|
rows.forEach((row) => {
|
|
const profileNumber = sanitizeProfileNumber(row.profile_number);
|
|
if (profileNumber) {
|
|
completedSet.add(profileNumber);
|
|
}
|
|
});
|
|
return completedSet;
|
|
}
|
|
|
|
function countUniqueProfileChecks(checks) {
|
|
const uniqueProfiles = new Set();
|
|
checks.forEach((check) => {
|
|
const profileNumber = sanitizeProfileNumber(check.profile_number);
|
|
if (profileNumber) {
|
|
uniqueProfiles.add(profileNumber);
|
|
}
|
|
});
|
|
return uniqueProfiles.size;
|
|
}
|
|
|
|
function shouldEnforceProfileOrder({ post, requiredProfiles, completedSet, ignoreOrder = false }) {
|
|
if (ignoreOrder) {
|
|
return false;
|
|
}
|
|
if (!post || !requiredProfiles.length) {
|
|
return false;
|
|
}
|
|
const isExpired = post.deadline_at ? new Date(post.deadline_at) < new Date() : false;
|
|
if (isExpired) {
|
|
return false;
|
|
}
|
|
const isComplete = requiredProfiles.every(profile => completedSet.has(profile));
|
|
return !isComplete;
|
|
}
|
|
|
|
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 = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks));
|
|
|
|
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,
|
|
first_image_hash TEXT,
|
|
first_image_url 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
|
|
);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS similarity_settings (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
text_threshold REAL DEFAULT ${SIMILARITY_DEFAULTS.text_threshold},
|
|
image_distance_threshold INTEGER DEFAULT ${SIMILARITY_DEFAULTS.image_distance_threshold},
|
|
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'];
|
|
const AI_COMMENT_RETRY_LIMIT = 5;
|
|
|
|
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 normalizeSimilarityTextThreshold(value) {
|
|
const parsed = parseFloat(value);
|
|
if (Number.isNaN(parsed)) {
|
|
return SIMILARITY_DEFAULTS.text_threshold;
|
|
}
|
|
return Math.min(0.99, Math.max(0.5, parsed));
|
|
}
|
|
|
|
function normalizeSimilarityImageDistance(value) {
|
|
const parsed = parseInt(value, 10);
|
|
if (Number.isNaN(parsed)) {
|
|
return SIMILARITY_DEFAULTS.image_distance_threshold;
|
|
}
|
|
return Math.min(64, Math.max(0, parsed));
|
|
}
|
|
|
|
function loadSimilaritySettings() {
|
|
let settings = db.prepare('SELECT * FROM similarity_settings WHERE id = 1').get();
|
|
if (!settings) {
|
|
db.prepare(`
|
|
INSERT INTO similarity_settings (id, text_threshold, image_distance_threshold, updated_at)
|
|
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
|
`).run(SIMILARITY_DEFAULTS.text_threshold, SIMILARITY_DEFAULTS.image_distance_threshold);
|
|
settings = {
|
|
text_threshold: SIMILARITY_DEFAULTS.text_threshold,
|
|
image_distance_threshold: SIMILARITY_DEFAULTS.image_distance_threshold
|
|
};
|
|
}
|
|
|
|
return {
|
|
text_threshold: normalizeSimilarityTextThreshold(settings.text_threshold),
|
|
image_distance_threshold: normalizeSimilarityImageDistance(settings.image_distance_threshold)
|
|
};
|
|
}
|
|
|
|
function persistSimilaritySettings({ textThreshold, imageDistanceThreshold }) {
|
|
const normalizedText = normalizeSimilarityTextThreshold(textThreshold);
|
|
const normalizedImage = normalizeSimilarityImageDistance(imageDistanceThreshold);
|
|
const existing = db.prepare('SELECT id FROM similarity_settings WHERE id = 1').get();
|
|
if (existing) {
|
|
db.prepare(`
|
|
UPDATE similarity_settings
|
|
SET text_threshold = ?, image_distance_threshold = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1
|
|
`).run(normalizedText, normalizedImage);
|
|
} else {
|
|
db.prepare(`
|
|
INSERT INTO similarity_settings (id, text_threshold, image_distance_threshold, updated_at)
|
|
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
|
`).run(normalizedText, normalizedImage);
|
|
}
|
|
|
|
return {
|
|
text_threshold: normalizedText,
|
|
image_distance_threshold: normalizedImage
|
|
};
|
|
}
|
|
|
|
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 selectPostsForSimilarityStmt = db.prepare(`
|
|
SELECT id, url, title, created_by_name, created_at, post_text, first_image_hash
|
|
FROM posts
|
|
`);
|
|
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 = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks));
|
|
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,
|
|
first_image_hash: post.first_image_hash || null,
|
|
first_image_url: post.first_image_url || 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, '<br>') : '';
|
|
|
|
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/posts/similar', (req, res) => {
|
|
try {
|
|
const { url, post_text, first_image_hash } = req.body || {};
|
|
const normalizedUrl = normalizeFacebookPostUrl(url);
|
|
if (!normalizedUrl) {
|
|
return res.status(400).json({ error: 'url must be a valid Facebook link' });
|
|
}
|
|
|
|
const existing = findPostByUrl(normalizedUrl);
|
|
if (existing) {
|
|
return res.json({ match: null });
|
|
}
|
|
|
|
const settings = loadSimilaritySettings();
|
|
const normalizedText = normalizePostText(post_text);
|
|
const textEligible = normalizedText && normalizedText.length >= MIN_SIMILAR_TEXT_LENGTH;
|
|
const normalizedImageHash = typeof first_image_hash === 'string'
|
|
? first_image_hash.trim().toLowerCase()
|
|
: null;
|
|
const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash))
|
|
? normalizedImageHash
|
|
: null;
|
|
|
|
let bestText = null;
|
|
let bestImage = null;
|
|
|
|
const rows = selectPostsForSimilarityStmt.all();
|
|
for (const row of rows) {
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
if (textEligible && row.post_text) {
|
|
const candidateText = normalizePostText(row.post_text);
|
|
if (candidateText) {
|
|
const score = computeTextSimilarity(normalizedText, candidateText);
|
|
if (!bestText || score > bestText.score) {
|
|
bestText = { post: row, score };
|
|
}
|
|
}
|
|
}
|
|
if (cleanedImageHash && row.first_image_hash) {
|
|
const distance = hammingDistanceHex(cleanedImageHash, row.first_image_hash);
|
|
if (distance !== null && (!bestImage || distance < bestImage.distance)) {
|
|
bestImage = { post: row, distance };
|
|
}
|
|
}
|
|
}
|
|
|
|
const textMatch = bestText && bestText.score >= settings.text_threshold ? bestText : null;
|
|
const imageMatch = bestImage && bestImage.distance <= settings.image_distance_threshold ? bestImage : null;
|
|
|
|
if (!textMatch && !imageMatch) {
|
|
return res.json({ match: null });
|
|
}
|
|
|
|
const imageScore = (match) => match ? 1 - (match.distance / 64) : 0;
|
|
let selected = textMatch || imageMatch;
|
|
let reason = textMatch ? 'text' : 'image';
|
|
let textScore = textMatch ? textMatch.score : null;
|
|
let imageDistance = imageMatch ? imageMatch.distance : null;
|
|
|
|
if (textMatch && imageMatch) {
|
|
if (textMatch.post.id === imageMatch.post.id) {
|
|
selected = textMatch;
|
|
reason = 'text+image';
|
|
} else {
|
|
const textScoreValue = textMatch.score;
|
|
const imageScoreValue = imageScore(imageMatch);
|
|
if (imageScoreValue > textScoreValue) {
|
|
selected = imageMatch;
|
|
reason = 'image';
|
|
} else {
|
|
selected = textMatch;
|
|
reason = 'text';
|
|
}
|
|
}
|
|
}
|
|
|
|
const matchPost = selected.post;
|
|
const responsePayload = {
|
|
match: {
|
|
id: matchPost.id,
|
|
url: matchPost.url,
|
|
title: matchPost.title,
|
|
created_by_name: matchPost.created_by_name,
|
|
created_at: sqliteTimestampToUTC(matchPost.created_at)
|
|
},
|
|
similarity: {
|
|
text: textScore,
|
|
image_distance: imageDistance
|
|
},
|
|
reason
|
|
};
|
|
|
|
res.json(responsePayload);
|
|
} 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 normalizedImageHash = typeof req.body.first_image_hash === 'string'
|
|
? req.body.first_image_hash.trim().toLowerCase()
|
|
: null;
|
|
const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash))
|
|
? normalizedImageHash
|
|
: null;
|
|
const normalizedImageUrl = typeof req.body.first_image_url === 'string'
|
|
? normalizeFacebookPostUrl(req.body.first_image_url) || req.body.first_image_url.trim()
|
|
: null;
|
|
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,
|
|
first_image_hash,
|
|
first_image_url,
|
|
last_change
|
|
)
|
|
VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
`);
|
|
stmt.run(
|
|
id,
|
|
normalizedUrl,
|
|
title || '',
|
|
validatedTargetCount,
|
|
creatorProfile,
|
|
creatorDisplayName,
|
|
normalizedDeadline,
|
|
normalizedPostText,
|
|
postTextHash,
|
|
contentKey || null,
|
|
cleanedImageHash || null,
|
|
normalizedImageUrl || 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, ignore_order } = 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];
|
|
}
|
|
|
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
|
|
const completedSet = buildCompletedProfileSet(completedRows);
|
|
const shouldEnforceOrder = shouldEnforceProfileOrder({
|
|
post,
|
|
requiredProfiles,
|
|
completedSet,
|
|
ignoreOrder: !!ignore_order
|
|
});
|
|
|
|
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 (shouldEnforceOrder && requiredProfiles.length > 0 && !isCreator) {
|
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
|
if (prerequisiteProfiles.length) {
|
|
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, skip_content_key_check, first_image_hash, first_image_url } = 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 : [];
|
|
let alternateUrls = [];
|
|
const skipContentKey = !!skip_content_key_check;
|
|
if (skipContentKey) {
|
|
const normalized = collectNormalizedFacebookUrls(post.url, candidateList);
|
|
alternateUrls = normalized.filter(url => url !== post.url);
|
|
} else {
|
|
alternateUrls = collectPostAlternateUrls(post.url, candidateList);
|
|
}
|
|
storePostUrls(post.id, post.url, alternateUrls, { skipContentKeyCheck: skipContentKey });
|
|
removeSearchSeenEntries([post.url, ...alternateUrls]);
|
|
|
|
const normalizedImageHash = typeof first_image_hash === 'string'
|
|
? first_image_hash.trim().toLowerCase()
|
|
: null;
|
|
const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash))
|
|
? normalizedImageHash
|
|
: null;
|
|
const normalizedImageUrl = typeof first_image_url === 'string'
|
|
? normalizeFacebookPostUrl(first_image_url) || first_image_url.trim()
|
|
: null;
|
|
|
|
if ((!post.first_image_hash && cleanedImageHash) || (!post.first_image_url && normalizedImageUrl)) {
|
|
db.prepare(`
|
|
UPDATE posts
|
|
SET first_image_hash = COALESCE(first_image_hash, ?),
|
|
first_image_url = COALESCE(first_image_url, ?),
|
|
last_change = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`).run(cleanedImageHash || null, normalizedImageUrl || null, post.id);
|
|
}
|
|
|
|
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, ignore_order } = 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];
|
|
}
|
|
|
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id);
|
|
const completedSet = buildCompletedProfileSet(completedRows);
|
|
const shouldEnforceOrder = shouldEnforceProfileOrder({
|
|
post,
|
|
requiredProfiles,
|
|
completedSet,
|
|
ignoreOrder: !!ignore_order
|
|
});
|
|
|
|
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 (shouldEnforceOrder && requiredProfiles.length > 0 && !isCreator) {
|
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
|
if (prerequisiteProfiles.length) {
|
|
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, ignore_order } = 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;
|
|
|
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
|
|
const completedSet = buildCompletedProfileSet(completedRows);
|
|
const shouldEnforceOrder = shouldEnforceProfileOrder({
|
|
post,
|
|
requiredProfiles,
|
|
completedSet,
|
|
ignoreOrder: !!ignore_order
|
|
});
|
|
|
|
if (shouldEnforceOrder && !isCreator) {
|
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
|
if (prerequisiteProfiles.length) {
|
|
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 mergedImageHash = primaryPost.first_image_hash || secondaryPost.first_image_hash || null;
|
|
const mergedImageUrl = primaryPost.first_image_url || secondaryPost.first_image_url || 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 = ?, first_image_hash = ?, first_image_url = ?, last_change = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`).run(
|
|
normalizedPrimaryUrl,
|
|
primaryContentKey || null,
|
|
mergedTargetCount,
|
|
mergedCreatorName,
|
|
mergedCreatorProfile,
|
|
mergedDeadline,
|
|
mergedTitle,
|
|
normalizedMergedPostText,
|
|
mergedPostTextHash,
|
|
mergedImageHash,
|
|
mergedImageUrl,
|
|
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/similarity-settings', (req, res) => {
|
|
try {
|
|
const settings = loadSimilaritySettings();
|
|
res.json(settings);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/similarity-settings', (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const saved = persistSimilaritySettings({
|
|
textThreshold: body.text_threshold,
|
|
imageDistanceThreshold: body.image_distance_threshold
|
|
});
|
|
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, '');
|
|
cleaned = cleaned.replace(/```+/g, '');
|
|
cleaned = cleaned.replace(/\*\*[^*]+\*\*/g, '');
|
|
cleaned = cleaned.replace(/`@([^`]+)`/g, '@$1');
|
|
cleaned = cleaned.replace(/`([^`]+)`/g, '$1');
|
|
|
|
const lines = cleaned.split(/\r?\n/);
|
|
while (lines.length) {
|
|
const line = lines[0].trim();
|
|
if (!line) {
|
|
lines.shift();
|
|
continue;
|
|
}
|
|
const labelLine = /^(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[:\-–—]*\s*$/i;
|
|
if (labelLine.test(line)) {
|
|
lines.shift();
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
cleaned = lines.join('\n').replace(/\s*,\s*/g, ', ').replace(/,+\s*$/g, '');
|
|
|
|
// Clean up AI output: drop hidden tags, replace dashes, normalize spacing.
|
|
return cleaned
|
|
.replace(/<think>[\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(/,+$/g, '')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function shouldRetryAIComment(text) {
|
|
if (!text || typeof text !== 'string') {
|
|
return false;
|
|
}
|
|
const lower = text.toLowerCase();
|
|
const hasCommentOrCharacter = lower.includes('comment') || lower.includes('character');
|
|
const hasLengthOrCount = lower.includes('length') || lower.includes('count');
|
|
return hasCommentOrCharacter && hasLengthOrCount;
|
|
}
|
|
|
|
async function tryGenerateComment(credential, promptPrefix, postText) {
|
|
const provider = credential.provider;
|
|
const apiKey = credential.api_key;
|
|
const model = credential.model;
|
|
|
|
let lastResponse = null;
|
|
|
|
try {
|
|
for (let attempt = 1; attempt <= AI_COMMENT_RETRY_LIMIT; attempt += 1) {
|
|
let comment = '';
|
|
|
|
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}`);
|
|
}
|
|
|
|
if (shouldRetryAIComment(comment)) {
|
|
if (attempt < AI_COMMENT_RETRY_LIMIT) {
|
|
continue;
|
|
}
|
|
const error = new Error('AI response contains forbidden comment length/count metadata');
|
|
error.provider = provider;
|
|
throw error;
|
|
}
|
|
|
|
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}`);
|
|
});
|