1945 lines
61 KiB
JavaScript
1945 lines
61 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 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 = 3;
|
|
const SEARCH_POST_RETENTION_DAYS = 90;
|
|
|
|
const screenshotDir = path.join(__dirname, 'data', 'screenshots');
|
|
if (!fs.existsSync(screenshotDir)) {
|
|
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
}
|
|
|
|
// 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();
|
|
});
|
|
|
|
// 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);
|
|
|
|
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;
|
|
}
|
|
|
|
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 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 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;
|
|
}
|
|
|
|
const cleanedParams = new URLSearchParams();
|
|
parsed.searchParams.forEach((paramValue, paramKey) => {
|
|
const lowerKey = paramKey.toLowerCase();
|
|
if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') {
|
|
return;
|
|
}
|
|
if (lowerKey === 'hoisted_section_header_type') {
|
|
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 search = cleanedParams.toString();
|
|
const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`;
|
|
return formatted.replace(/[?&]$/, '');
|
|
}
|
|
|
|
function getRequiredProfiles(targetCount) {
|
|
const count = clampTargetCount(targetCount);
|
|
return Array.from({ length: count }, (_, index) => index + 1);
|
|
}
|
|
|
|
function buildProfileStatuses(requiredProfiles, checks) {
|
|
const validChecks = checks
|
|
.map(check => {
|
|
const profileNumber = sanitizeProfileNumber(check.profile_number);
|
|
if (!profileNumber) {
|
|
return null;
|
|
}
|
|
return {
|
|
...check,
|
|
profile_number: profileNumber,
|
|
profile_name: getProfileName(profileNumber)
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const completedSet = new Set(validChecks.map(check => check.profile_number));
|
|
const checkByProfile = new Map(validChecks.map(check => [check.profile_number, check]));
|
|
|
|
const statuses = requiredProfiles.map((profileNumber, index) => {
|
|
const prerequisites = requiredProfiles.slice(0, index);
|
|
const prerequisitesMet = prerequisites.every(num => completedSet.has(num));
|
|
const isChecked = completedSet.has(profileNumber);
|
|
return {
|
|
profile_number: profileNumber,
|
|
profile_name: getProfileName(profileNumber),
|
|
status: isChecked ? 'done' : (prerequisitesMet ? 'available' : 'locked'),
|
|
checked_at: isChecked && checkByProfile.get(profileNumber)
|
|
? checkByProfile.get(profileNumber).checked_at
|
|
: null
|
|
};
|
|
});
|
|
|
|
return {
|
|
statuses,
|
|
completedChecks: validChecks,
|
|
completedSet
|
|
};
|
|
}
|
|
|
|
function recalcCheckedCount(postId) {
|
|
const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId);
|
|
if (!post) {
|
|
return null;
|
|
}
|
|
|
|
const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId);
|
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
|
const { statuses } = buildProfileStatuses(requiredProfiles, checks);
|
|
const checkedCount = statuses.filter(status => status.status === 'done').length;
|
|
|
|
const updates = [];
|
|
const params = [];
|
|
|
|
if (post.checked_count !== checkedCount) {
|
|
updates.push('checked_count = ?');
|
|
params.push(checkedCount);
|
|
}
|
|
|
|
if (post.target_count !== requiredProfiles.length) {
|
|
updates.push('target_count = ?');
|
|
params.push(requiredProfiles.length);
|
|
}
|
|
|
|
if (updates.length) {
|
|
updates.push('last_change = CURRENT_TIMESTAMP');
|
|
params.push(postId);
|
|
db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
}
|
|
|
|
return checkedCount;
|
|
}
|
|
|
|
// Initialize database tables
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS posts (
|
|
id TEXT PRIMARY KEY,
|
|
url TEXT NOT NULL UNIQUE,
|
|
title TEXT,
|
|
target_count INTEGER NOT NULL,
|
|
checked_count INTEGER DEFAULT 0,
|
|
screenshot_path TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
last_change DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`);
|
|
|
|
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 INDEX IF NOT EXISTS idx_search_seen_posts_last_seen_at
|
|
ON search_seen_posts(last_seen_at);
|
|
`);
|
|
|
|
const ensureColumn = (table, column, definition) => {
|
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
if (!columns.some(col => col.name === column)) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
|
|
}
|
|
};
|
|
|
|
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('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0');
|
|
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) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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 cleanupExpiredSearchPosts() {
|
|
try {
|
|
const threshold = `-${SEARCH_POST_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 collectNormalizedFacebookUrls(primaryUrl, candidates = []) {
|
|
const normalized = [];
|
|
|
|
const pushNormalized = (value) => {
|
|
const normalizedUrl = normalizeFacebookPostUrl(value);
|
|
if (normalizedUrl && !normalized.includes(normalizedUrl)) {
|
|
normalized.push(normalizedUrl);
|
|
}
|
|
};
|
|
|
|
if (primaryUrl) {
|
|
pushNormalized(primaryUrl);
|
|
}
|
|
|
|
if (Array.isArray(candidates)) {
|
|
for (const candidate of candidates) {
|
|
pushNormalized(candidate);
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
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, 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, first_seen_at, last_seen_at)
|
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
`);
|
|
const updateSearchSeenStmt = db.prepare(`
|
|
UPDATE search_seen_posts
|
|
SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP
|
|
WHERE url = ?
|
|
`);
|
|
const deleteSearchSeenStmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?');
|
|
const selectTrackedPostStmt = db.prepare('SELECT id FROM posts 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;
|
|
}
|
|
|
|
const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id);
|
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
|
const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks);
|
|
const checkedCount = statuses.filter(status => status.status === 'done').length;
|
|
const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null;
|
|
const screenshotPath = screenshotFile && fs.existsSync(screenshotFile)
|
|
? `/api/posts/${post.id}/screenshot`
|
|
: null;
|
|
|
|
let postLastChange = post.last_change;
|
|
|
|
if (post.checked_count !== checkedCount || post.target_count !== requiredProfiles.length) {
|
|
const updates = [];
|
|
const params = [];
|
|
|
|
if (post.checked_count !== checkedCount) {
|
|
updates.push('checked_count = ?');
|
|
params.push(checkedCount);
|
|
}
|
|
|
|
if (post.target_count !== requiredProfiles.length) {
|
|
updates.push('target_count = ?');
|
|
params.push(requiredProfiles.length);
|
|
}
|
|
|
|
updates.push('last_change = CURRENT_TIMESTAMP');
|
|
params.push(post.id);
|
|
db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
|
|
const refreshed = db.prepare('SELECT last_change FROM posts WHERE id = ?').get(post.id);
|
|
if (refreshed && refreshed.last_change) {
|
|
postLastChange = refreshed.last_change;
|
|
}
|
|
}
|
|
|
|
const nextRequired = statuses.find(status => status.status === 'available');
|
|
const creatorProfile = sanitizeProfileNumber(post.created_by_profile);
|
|
const creatorName = normalizeCreatorName(post.created_by_name);
|
|
|
|
// Convert SQLite timestamps to UTC ISO-8601 format
|
|
const checksWithUTC = completedChecks.map(check => ({
|
|
...check,
|
|
checked_at: sqliteTimestampToUTC(check.checked_at)
|
|
}));
|
|
|
|
const statusesWithUTC = statuses.map(status => ({
|
|
...status,
|
|
checked_at: sqliteTimestampToUTC(status.checked_at)
|
|
}));
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
// Get all posts
|
|
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 = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl);
|
|
|
|
if (!post) {
|
|
return res.json(null);
|
|
}
|
|
|
|
res.json(mapPostRow(post));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/search-posts', (req, res) => {
|
|
try {
|
|
const { url, candidates, skip_increment, force_hide } = 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 isTracked = false;
|
|
for (const candidate of normalizedUrls) {
|
|
const tracked = selectTrackedPostStmt.get(candidate);
|
|
if (tracked) {
|
|
isTracked = true;
|
|
deleteSearchSeenStmt.run(candidate);
|
|
}
|
|
}
|
|
|
|
if (isTracked) {
|
|
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;
|
|
|
|
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;
|
|
if (row) {
|
|
updateSearchSeenStmt.run(candidateCount, 1, candidate);
|
|
} else {
|
|
insertSearchSeenStmt.run(candidate, candidateCount, 1);
|
|
}
|
|
}
|
|
|
|
return res.json({ seen_count: desiredCount, should_hide: true, manually_hidden: true });
|
|
}
|
|
|
|
if (skip_increment) {
|
|
if (!existingRow) {
|
|
return res.json({ seen_count: 0, should_hide: false, manually_hidden: false });
|
|
}
|
|
const seenCount = existingRow.seen_count;
|
|
const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || existingManualHidden;
|
|
return res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: existingManualHidden });
|
|
}
|
|
|
|
let seenCount = existingRow ? existingRow.seen_count + 1 : 1;
|
|
const manualHidden = existingManualHidden;
|
|
|
|
if (existingRow) {
|
|
updateSearchSeenStmt.run(seenCount, manualHidden ? 1 : 0, targetUrl);
|
|
} else {
|
|
insertSearchSeenStmt.run(targetUrl, seenCount, manualHidden ? 1 : 0);
|
|
}
|
|
|
|
const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || manualHidden;
|
|
res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: manualHidden });
|
|
} 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);
|
|
res.json(mapPostRow(updatedPost));
|
|
} 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', 'public, max-age=86400');
|
|
return res.sendFile(placeholderPath);
|
|
}
|
|
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', 'public, max-age=86400');
|
|
return res.sendFile(placeholderPath);
|
|
}
|
|
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
|
|
} = req.body;
|
|
|
|
const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count);
|
|
|
|
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();
|
|
|
|
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, last_change)
|
|
VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
`);
|
|
stmt.run(id, normalizedUrl, title || '', validatedTargetCount, creatorProfile, creatorDisplayName, normalizedDeadline);
|
|
|
|
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
|
|
|
|
removeSearchSeenEntries([normalizedUrl]);
|
|
|
|
res.json(mapPostRow(post));
|
|
} 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 } = req.body || {};
|
|
|
|
const updates = [];
|
|
const params = [];
|
|
let normalizedUrlForCleanup = 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;
|
|
}
|
|
|
|
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 = ?`);
|
|
let result;
|
|
try {
|
|
result = 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;
|
|
}
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ error: 'Post not found' });
|
|
}
|
|
|
|
recalcCheckedCount(postId);
|
|
|
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|
|
|
if (normalizedUrlForCleanup) {
|
|
removeSearchSeenEntries([normalizedUrlForCleanup]);
|
|
}
|
|
|
|
res.json(mapPostRow(updatedPost));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Check a post for a profile
|
|
app.post('/api/posts/:postId/check', (req, res) => {
|
|
try {
|
|
const { postId } = req.params;
|
|
const { profile_number } = req.body;
|
|
|
|
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|
if (!post) {
|
|
return res.status(404).json({ error: 'Post not found' });
|
|
}
|
|
|
|
// Check if deadline has passed
|
|
if (post.deadline_at) {
|
|
const deadline = new Date(post.deadline_at);
|
|
if (new Date() > deadline) {
|
|
return res.status(400).json({ error: 'Deadline ist abgelaufen' });
|
|
}
|
|
}
|
|
|
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
|
let didChange = false;
|
|
if (post.target_count !== requiredProfiles.length) {
|
|
db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, postId);
|
|
post.target_count = requiredProfiles.length;
|
|
didChange = true;
|
|
}
|
|
|
|
let profileValue = sanitizeProfileNumber(profile_number);
|
|
if (!profileValue) {
|
|
const storedProfile = getScopedProfileNumber(req.profileScope);
|
|
profileValue = storedProfile || requiredProfiles[0];
|
|
}
|
|
|
|
if (!requiredProfiles.includes(profileValue)) {
|
|
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' });
|
|
}
|
|
|
|
const existingCheck = db.prepare(
|
|
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
|
|
).get(postId, profileValue);
|
|
|
|
if (existingCheck) {
|
|
const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|
return res.json(mapPostRow(existingPost));
|
|
}
|
|
|
|
// Allow creator to check immediately, regardless of profile number
|
|
const isCreator = post.created_by_profile === profileValue;
|
|
|
|
if (requiredProfiles.length > 0 && !isCreator) {
|
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
|
if (prerequisiteProfiles.length) {
|
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
|
|
const completedSet = new Set(
|
|
completedRows
|
|
.map(row => sanitizeProfileNumber(row.profile_number))
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
|
|
if (missingPrerequisites.length) {
|
|
return res.status(409).json({
|
|
error: 'Vorherige Profile müssen zuerst bestätigen.',
|
|
missing_profiles: missingPrerequisites
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const insertStmt = db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)');
|
|
insertStmt.run(postId, profileValue);
|
|
didChange = true;
|
|
recalcCheckedCount(postId);
|
|
if (didChange) {
|
|
touchPost(postId);
|
|
}
|
|
|
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|
|
|
res.json(mapPostRow(updatedPost));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Check by URL (for web interface auto-check)
|
|
app.post('/api/check-by-url', (req, res) => {
|
|
try {
|
|
const { url, profile_number } = req.body;
|
|
|
|
if (!url) {
|
|
return res.status(400).json({ error: 'URL is required' });
|
|
}
|
|
|
|
const normalizedUrl = normalizeFacebookPostUrl(url);
|
|
if (!normalizedUrl) {
|
|
return res.status(400).json({ error: 'URL must be a valid Facebook link' });
|
|
}
|
|
|
|
const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl);
|
|
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, post.id);
|
|
post.target_count = requiredProfiles.length;
|
|
didChange = true;
|
|
}
|
|
|
|
let profileValue = sanitizeProfileNumber(profile_number);
|
|
if (!profileValue) {
|
|
const storedProfile = getScopedProfileNumber(req.profileScope);
|
|
profileValue = storedProfile || requiredProfiles[0];
|
|
}
|
|
|
|
if (!requiredProfiles.includes(profileValue)) {
|
|
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' });
|
|
}
|
|
|
|
const existingCheck = db.prepare(
|
|
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
|
|
).get(post.id, profileValue);
|
|
|
|
if (existingCheck) {
|
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id);
|
|
return res.json(mapPostRow(updatedPost));
|
|
}
|
|
|
|
// Allow creator to check immediately, regardless of profile number
|
|
const isCreator = post.created_by_profile === profileValue;
|
|
|
|
if (requiredProfiles.length > 0 && !isCreator) {
|
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
|
if (prerequisiteProfiles.length) {
|
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id);
|
|
const completedSet = new Set(
|
|
completedRows
|
|
.map(row => sanitizeProfileNumber(row.profile_number))
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
|
|
if (missingPrerequisites.length) {
|
|
return res.status(409).json({
|
|
error: 'Vorherige Profile müssen zuerst bestätigen.',
|
|
missing_profiles: missingPrerequisites
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(post.id, profileValue);
|
|
didChange = true;
|
|
recalcCheckedCount(post.id);
|
|
if (didChange) {
|
|
touchPost(post.id);
|
|
}
|
|
|
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id);
|
|
|
|
res.json(mapPostRow(updatedPost));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/posts/:postId/profile-status', (req, res) => {
|
|
try {
|
|
const { postId } = req.params;
|
|
const { profile_number, status } = req.body || {};
|
|
|
|
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|
if (!post) {
|
|
return res.status(404).json({ error: 'Post not found' });
|
|
}
|
|
|
|
// Check if deadline has passed (only for setting to 'done')
|
|
if (status === 'done' && post.deadline_at) {
|
|
const deadline = new Date(post.deadline_at);
|
|
if (new Date() > deadline) {
|
|
return res.status(400).json({ error: 'Deadline ist abgelaufen' });
|
|
}
|
|
}
|
|
|
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
|
let didChange = false;
|
|
if (post.target_count !== requiredProfiles.length) {
|
|
db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id);
|
|
post.target_count = requiredProfiles.length;
|
|
didChange = true;
|
|
}
|
|
|
|
const profileValue = sanitizeProfileNumber(profile_number);
|
|
if (!profileValue) {
|
|
return res.status(400).json({ error: 'Valid profile_number required' });
|
|
}
|
|
|
|
if (!requiredProfiles.includes(profileValue)) {
|
|
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' });
|
|
}
|
|
|
|
const normalizedStatus = status === 'done' ? 'done' : 'pending';
|
|
|
|
if (normalizedStatus === 'done') {
|
|
// Allow creator to check immediately, regardless of profile number
|
|
const isCreator = post.created_by_profile === profileValue;
|
|
|
|
if (!isCreator) {
|
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
|
if (prerequisiteProfiles.length) {
|
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
|
|
const completedSet = new Set(
|
|
completedRows
|
|
.map(row => sanitizeProfileNumber(row.profile_number))
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
|
|
if (missingPrerequisites.length) {
|
|
return res.status(409).json({
|
|
error: 'Vorherige Profile müssen zuerst bestätigen.',
|
|
missing_profiles: missingPrerequisites
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const existingCheck = db.prepare(
|
|
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
|
|
).get(postId, profileValue);
|
|
|
|
if (!existingCheck) {
|
|
db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(postId, profileValue);
|
|
didChange = true;
|
|
}
|
|
} else {
|
|
db.prepare('DELETE FROM checks WHERE post_id = ? AND profile_number = ?').run(postId, profileValue);
|
|
didChange = true;
|
|
}
|
|
|
|
recalcCheckedCount(postId);
|
|
if (didChange) {
|
|
touchPost(postId);
|
|
}
|
|
|
|
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 post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId);
|
|
if (!post) {
|
|
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' });
|
|
}
|
|
|
|
// Update URL
|
|
db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId);
|
|
removeSearchSeenEntries([normalizedUrl]);
|
|
return res.json({ success: true, url: normalizedUrl });
|
|
}
|
|
|
|
if (is_successful !== undefined) {
|
|
const successValue = is_successful ? 1 : 0;
|
|
db.prepare('UPDATE posts SET is_successful = ? WHERE id = ?').run(successValue, postId);
|
|
|
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|
return res.json(mapPostRow(updatedPost));
|
|
}
|
|
|
|
return res.status(400).json({ error: 'No valid update parameter provided' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// 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 });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// AI Credentials endpoints
|
|
app.get('/api/ai-credentials', (req, res) => {
|
|
try {
|
|
const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all();
|
|
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 = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(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 = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(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' });
|
|
}
|
|
|
|
db.prepare(`
|
|
UPDATE ai_credentials
|
|
SET is_active = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`).run(is_active, id);
|
|
|
|
const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials WHERE id = ?').get(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 = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all();
|
|
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 = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(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 = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(updated.active_credential_id);
|
|
}
|
|
|
|
res.json({
|
|
...updated,
|
|
active_credential: activeCredential
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
function sanitizeAIComment(text) {
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
|
|
return text
|
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
async function tryGenerateComment(credential, promptPrefix, postText) {
|
|
const provider = credential.provider;
|
|
const apiKey = credential.api_key;
|
|
const model = credential.model;
|
|
|
|
let comment = '';
|
|
|
|
if (provider === 'gemini') {
|
|
// Gemini API
|
|
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 }]
|
|
}]
|
|
})
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(`Gemini API error: ${errorData.error?.message || response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
comment = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
|
|
} else if (provider === 'openai') {
|
|
// OpenAI/ChatGPT API
|
|
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
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
const message = errorData.error?.message || response.statusText;
|
|
throw new Error(`OpenAI API error: ${message}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
comment = data.choices?.[0]?.message?.content || '';
|
|
|
|
} else if (provider === 'claude') {
|
|
// Anthropic Claude API
|
|
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 }]
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
comment = data.content?.[0]?.text || '';
|
|
|
|
} else {
|
|
throw new Error(`Unsupported AI provider: ${provider}`);
|
|
}
|
|
|
|
return sanitizeAIComment(comment);
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
// Get all active credentials, ordered by priority
|
|
const credentials = db.prepare('SELECT * FROM ai_credentials WHERE is_active = 1 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;
|
|
for (const credential of orderedCredentials) {
|
|
try {
|
|
console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`);
|
|
const comment = await tryGenerateComment(credential, promptPrefix, postText);
|
|
console.log(`Success with credential: ${credential.name}`);
|
|
return res.json({ comment, usedCredential: credential.name });
|
|
} catch (error) {
|
|
console.error(`Failed with credential ${credential.name}:`, error.message);
|
|
lastError = error;
|
|
// Continue to next credential
|
|
}
|
|
}
|
|
|
|
// If we get here, all credentials failed
|
|
throw lastError || new Error('All AI credentials failed');
|
|
|
|
} catch (error) {
|
|
console.error('AI comment generation error:', error);
|
|
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' });
|
|
});
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Server running on port ${PORT}`);
|
|
});
|