aktueller stand
This commit is contained in:
@@ -30,6 +30,16 @@ 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_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 SPORTS_SCORING_DEFAULTS = {
|
||||
enabled: 1,
|
||||
threshold: 5,
|
||||
@@ -1277,6 +1287,178 @@ const deleteDailyBookmarkCheckStmt = db.prepare(`
|
||||
AND day_key = @dayKey
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS automation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
method TEXT NOT NULL DEFAULT 'GET',
|
||||
url_template TEXT NOT NULL,
|
||||
headers_json TEXT,
|
||||
body_template 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
|
||||
);
|
||||
`);
|
||||
|
||||
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,
|
||||
method,
|
||||
url_template,
|
||||
headers_json,
|
||||
body_template,
|
||||
interval_minutes,
|
||||
jitter_minutes,
|
||||
start_at,
|
||||
run_until,
|
||||
active,
|
||||
last_run_at,
|
||||
last_status,
|
||||
last_status_code,
|
||||
last_error,
|
||||
next_run_at,
|
||||
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,
|
||||
method,
|
||||
url_template,
|
||||
headers_json,
|
||||
body_template,
|
||||
interval_minutes,
|
||||
jitter_minutes,
|
||||
start_at,
|
||||
run_until,
|
||||
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, method, url_template, headers_json, body_template,
|
||||
interval_minutes, jitter_minutes, start_at, run_until, active, last_run_at,
|
||||
last_status, last_status_code, last_error, next_run_at
|
||||
) VALUES (
|
||||
@id, @name, @description, @method, @url_template, @headers_json, @body_template,
|
||||
@interval_minutes, @jitter_minutes, @start_at, @run_until, @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,
|
||||
method = @method,
|
||||
url_template = @url_template,
|
||||
headers_json = @headers_json,
|
||||
body_template = @body_template,
|
||||
interval_minutes = @interval_minutes,
|
||||
jitter_minutes = @jitter_minutes,
|
||||
start_at = @start_at,
|
||||
run_until = @run_until,
|
||||
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');
|
||||
@@ -1613,6 +1795,424 @@ function determineAutoDisable(error) {
|
||||
};
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
const candidate = new Date(base.getTime() + jitterMs);
|
||||
|
||||
if (request.run_until) {
|
||||
const until = new Date(request.run_until);
|
||||
if (!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,
|
||||
description: row.description || '',
|
||||
method: row.method || 'GET',
|
||||
url_template: row.url_template,
|
||||
headers,
|
||||
body_template: row.body_template || '',
|
||||
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,
|
||||
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 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 (!urlTemplate) {
|
||||
errors.push('URL-Template fehlt');
|
||||
} else {
|
||||
normalized.url_template = truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH);
|
||||
}
|
||||
|
||||
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
|
||||
: '';
|
||||
}
|
||||
|
||||
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.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;
|
||||
@@ -2573,6 +3173,313 @@ function broadcastPostDeletion(postId, options = {}) {
|
||||
broadcastSseEvent(payload);
|
||||
}
|
||||
|
||||
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 method = (current.method || 'GET').toUpperCase();
|
||||
const url = renderAutomationTemplate(current.url_template, context);
|
||||
const headers = renderAutomationHeaders(current.headers_json, context);
|
||||
const shouldSendBody = !['GET', 'HEAD'].includes(method);
|
||||
const body = shouldSendBody && current.body_template
|
||||
? renderAutomationTemplate(current.body_template, context)
|
||||
: null;
|
||||
|
||||
const startedAt = new Date();
|
||||
let status = 'success';
|
||||
let statusCode = null;
|
||||
let responseText = '';
|
||||
let errorMessage = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: shouldSendBody && body !== null ? body : undefined,
|
||||
redirect: 'follow'
|
||||
});
|
||||
statusCode = response.status;
|
||||
try {
|
||||
responseText = await response.text();
|
||||
} catch (error) {
|
||||
responseText = '';
|
||||
}
|
||||
if (!response.ok) {
|
||||
status = 'error';
|
||||
errorMessage = `HTTP ${response.status}`;
|
||||
}
|
||||
} 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;
|
||||
|
||||
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();
|
||||
@@ -4726,6 +5633,8 @@ app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
startAutomationWorker();
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user