Dateien
AegisSight-Monitor/src/static/js/api.js
claude-dev 1bbbd891ef feat(abrechnung): Monatskontingent in Credits mit festen Sätzen, Hard-Stop und Warnschwelle
Portierung aus dem Lokal-Fork (AegisSight-Monitor-Local, Commit d2b9168,
per Cherry-Pick übernommen und an den Server-Stand angepasst).

- Feste Sätze je Aktion (BILLING_MODE=flat) statt echter Kosten geteilt
  durch cost_per_credit. Sätze auf die am 23.07.2026 beschlossenen
  Verkaufswerte gesetzt. Live-Lauf 45, Recherche je Durchlauf 40,
  Studio-Bausteine 12, Chat/Beschreibung/Globe 1. Per ENV überschreibbar,
  der alte Modus bleibt als BILLING_MODE=actual erhalten.
- Abrechnungsperiode. licenses.credits_period trennt monthly von total,
  träger Monatsreset bei der nächsten Lizenzprüfung, optionaler Übertrag
  (credits_rollover, gedeckelt auf ein Monatskontingent). Bestandslizenzen
  ohne Periodenmarke behalten ihren Verbrauch.
- Hard-Stop gegen das verfügbare Monatskontingent (Kontingent plus
  Übertrag) statt gegen das Lebenszeit-Total.
- Warnschwelle budget_warning_percent (Default 80 Prozent) wird erstmals
  ausgewertet, einmalige Meldung an alle aktiven Nutzer der Organisation.
- DB-Migration additiv und idempotent (credits_period, credits_period_start,
  credits_rollover, credits_carried, budget_warning_sent, unlimited_budget).
- /api/auth/me liefert credits_available als Bezugsgröße plus
  credits_period, das Credits-Widget zeigt "Credits diesen Monat".
- Wortlaut überall Credits (nicht Guthaben/Einheiten), docs/ABRECHNUNG.md
  auf den Online-Stand gebracht.

Abweichungen zur Fork-Vorlage. Die Takt-Änderungen (Untergrenze 30 Min,
Kostenvorschau im Anlege-Dialog, Fork-Commit 5b0b578) sind bewusst NICHT
enthalten, die Sätze stehen auf 45/40 statt der Fork-Defaults 24/33.

Getestet gegen eine Kopie der Staging-DB, 20 Prüfungen bestanden.
Migration, Flat-Buchung adhoc/research/chat, Warnschwelle einmalig,
Hard-Stop, Monatsreset, Übertrag gedeckelt, Bestandslizenz ohne Marke.
Die vier Live-Lizenzen stehen auf unlimited_budget und sind unbeeinflusst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:26:54 +00:00

437 Zeilen
15 KiB
JavaScript

/**
* API-Client für den OSINT Lagemonitor.
*/
class ApiError extends Error {
constructor(status, detail) {
super(detail || `Fehler ${status}`);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
}
}
const API = {
baseUrl: '/api',
_getHeaders() {
const token = localStorage.getItem('osint_token');
return {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
};
},
async upload(path, formData) {
const token = localStorage.getItem("osint_token");
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers,
body: formData,
});
if (response.status === 401) {
localStorage.removeItem("osint_token");
localStorage.removeItem("osint_username");
window.location.href = "/";
return;
}
if (!response.ok) {
const data = await response.json().catch(() => ({}));
let d = data.detail;
if (Array.isArray(d)) d = d.map(e => e.msg || JSON.stringify(e)).join("; ");
else if (typeof d === "object" && d !== null) d = JSON.stringify(d);
throw new Error(d || `Fehler ${response.status}`);
}
return response.json();
},
async _request(method, path, body = null, externalSignal = null) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
// Externen Abort weiterleiten an internen Controller
if (externalSignal) {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
const options = {
method,
headers: this._getHeaders(),
signal: controller.signal,
};
if (body) {
options.body = JSON.stringify(body);
}
let response;
try {
response = await fetch(`${this.baseUrl}${path}`, options);
} catch (err) {
clearTimeout(timeout);
if (err.name === 'AbortError') {
throw new Error('Zeitüberschreitung bei der Anfrage');
}
throw err;
}
clearTimeout(timeout);
if (response.status === 401) {
localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username');
window.location.href = '/';
return;
}
if (!response.ok) {
const data = await response.json().catch(() => ({}));
let detail = data.detail;
if (Array.isArray(detail)) {
detail = detail.map(e => e.msg || JSON.stringify(e)).join('; ');
} else if (typeof detail === 'object' && detail !== null) {
detail = JSON.stringify(detail);
}
// Lizenz-Status aus Header auslesen (vom Backend gesetzt bei 403)
const licStatus = response.headers.get('X-License-Status');
if (response.status === 403 && licStatus && typeof App !== 'undefined') {
if (!App.user) App.user = {};
App.user.read_only = true;
App.user.read_only_reason = licStatus;
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
let text = 'Nur Lesezugriff';
if (licStatus === 'budget_exceeded') text = 'Credits aufgebraucht, nur Lesezugriff. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.';
else if (licStatus === 'expired') text = 'Lizenz abgelaufen, nur Lesezugriff';
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz, nur Lesezugriff';
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert, nur Lesezugriff';
warningEl.textContent = text;
warningEl.classList.add('visible');
}
if (typeof App._updateRefreshButton === 'function') App._updateRefreshButton(false);
if (typeof UI !== 'undefined' && UI.showToast) {
UI.showToast(detail || 'Lizenz-Beschränkung – nur Lesezugriff', 'error');
}
}
throw new ApiError(response.status, detail);
}
if (response.status === 204) return null;
return response.json();
},
// Auth
getMe() {
return this._request('GET', '/auth/me');
},
// Incidents
listIncidents(statusFilter = null) {
const query = statusFilter ? `?status_filter=${statusFilter}` : '';
return this._request('GET', `/incidents${query}`);
},
enhanceDescription(title, description, type, signal = null) {
return this._request('POST', '/incidents/enhance-description', { title, description, type }, signal);
},
createIncident(data) {
return this._request('POST', '/incidents', data);
},
getRefreshingIncidents() {
return this._request('GET', '/incidents/refreshing');
},
getIncident(id) {
return this._request('GET', `/incidents/${id}`);
},
getIncidentSources(id) {
return this._request('GET', `/incidents/${id}/sources`);
},
updateIncident(id, data) {
return this._request('PUT', `/incidents/${id}`, data);
},
deleteIncident(id) {
return this._request('DELETE', `/incidents/${id}`);
},
getArticles(incidentId, { limit = 500, offset = 0, search = null } = {}) {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
if (search) params.set('search', search);
return this._request('GET', `/incidents/${incidentId}/articles?${params.toString()}`);
},
getArticlesSourcesSummary(incidentId) {
return this._request('GET', `/incidents/${incidentId}/articles/sources-summary`);
},
getArticlesTimelineBuckets(incidentId, granularity = 'day') {
return this._request('GET', `/incidents/${incidentId}/articles/timeline-buckets?granularity=${encodeURIComponent(granularity)}`);
},
getFactChecks(incidentId) {
return this._request('GET', `/incidents/${incidentId}/factchecks`);
},
getPipeline(incidentId) {
return this._request('GET', `/incidents/${incidentId}/pipeline`);
},
getSnapshots(incidentId) {
return this._request('GET', `/incidents/${incidentId}/snapshots`);
},
getSnapshot(incidentId, snapshotId) {
return this._request('GET', `/incidents/${incidentId}/snapshots/${snapshotId}`);
},
searchSnapshots(incidentId, query) {
return this._request('GET', `/incidents/${incidentId}/snapshots/search?q=${encodeURIComponent(query)}`);
},
getLocations(incidentId) {
return this._request('GET', `/incidents/${incidentId}/locations`);
},
triggerGeoparse(incidentId) {
return this._request('POST', `/incidents/${incidentId}/geoparse`);
},
getGeoparseStatus(incidentId) {
return this._request('GET', `/incidents/${incidentId}/geoparse-status`);
},
refreshIncident(id) {
return this._request('POST', `/incidents/${id}/refresh`);
},
getRefreshLog(incidentId, limit = 20) {
return this._request('GET', `/incidents/${incidentId}/refresh-log?limit=${limit}`);
},
// === Studio: Ereignis-Timeline, RAG-Chat, modulare Bausteine, Uploads, Faktencheck-Verlauf ===
// Backend-Endpunkte folgen phasenweise; fehlende liefern vorerst 404 (studio.js faengt das ab).
getEvents(incidentId, limit = 250) {
return this._request('GET', `/incidents/${incidentId}/events?limit=${encodeURIComponent(limit)}`);
},
// RAG-Chat: inhaltliche Frage über eine konkrete Lage (Studio-UI)
askIncident(incidentId, message, { conversation_id = null, source_filter = null, scope = 'fall' } = {}) {
return this._request('POST', `/incidents/${incidentId}/ask`, {
message,
conversation_id,
source_filter,
scope,
});
},
// Multimodaler Quellen-Ingest (Upload/URL -> Artikel)
createUploads(incidentId, { files = [], url = null } = {}) {
const fd = new FormData();
(files || []).forEach(f => fd.append('files', f));
if (url) fd.append('url', url);
return this.upload(`/incidents/${incidentId}/uploads`, fd);
},
listUploads(incidentId) {
return this._request('GET', `/incidents/${incidentId}/uploads`);
},
deleteUpload(incidentId, uploadId) {
return this._request('DELETE', `/incidents/${incidentId}/uploads/${uploadId}`);
},
async fetchUploadBlobUrl(incidentId, uploadId) {
const token = localStorage.getItem('osint_token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${this.baseUrl}/incidents/${incidentId}/uploads/${uploadId}/file`, { headers });
if (!res.ok) throw new Error(`Datei konnte nicht geladen werden (${res.status})`);
const blob = await res.blob();
return URL.createObjectURL(blob);
},
// Modulare Pipeline-Bausteine (Studio)
runStage(incidentId, stage) {
return this._request('POST', `/incidents/${incidentId}/run/${stage}`);
},
getRunStatus(incidentId) {
return this._request('GET', `/incidents/${incidentId}/run-status`);
},
// Recherche-Angebot des Fall-Chats ausfuehren (Beschreibung ergaenzen + gezielte Recherche)
clarify(incidentId, { focus, description_addition = '' } = {}) {
return this._request('POST', `/incidents/${incidentId}/clarify`, { focus, description_addition });
},
// Datenstand je Artefakt (erzeugt? wie alt? wie viele neue Artikel seither?)
getFreshness(incidentId) {
return this._request('GET', `/incidents/${incidentId}/freshness`);
},
// Kann die Websuche gerade Treffer liefern? (online via Claude-WebSearch, Stub)
getSearchStatus() {
return this._request('GET', '/system/search-status');
},
listFactcheckRuns(incidentId) {
return this._request('GET', `/incidents/${incidentId}/factcheck-runs`);
},
getFactcheckRun(incidentId, runId) {
return this._request('GET', `/incidents/${incidentId}/factcheck-runs/${runId}`);
},
// X-Zugänge (Studio; online-Router folgt in spaeterer Phase)
listXAccounts() {
return this._request('GET', '/x/accounts');
},
addXAccount(data) {
return this._request('POST', '/x/accounts', data);
},
deleteXAccount(username) {
return this._request('DELETE', `/x/accounts/${encodeURIComponent(username)}`);
},
// Sources (Quellenverwaltung)
listSources(params = {}) {
const query = new URLSearchParams();
if (params.source_type) query.set('source_type', params.source_type);
if (params.category) query.set('category', params.category);
if (params.source_status) query.set('source_status', params.source_status);
if (params.political_orientation) query.set('political_orientation', params.political_orientation);
if (params.media_type) query.set('media_type', params.media_type);
if (params.reliability) query.set('reliability', params.reliability);
if (params.alignment) query.set('alignment', params.alignment);
if (params.state_affiliated !== undefined && params.state_affiliated !== null) {
query.set('state_affiliated', String(params.state_affiliated));
}
const qs = query.toString();
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
},
createSource(data) {
return this._request('POST', '/sources', data);
},
updateSource(id, data) {
return this._request('PUT', `/sources/${id}`, data);
},
deleteSource(id) {
return this._request('DELETE', `/sources/${id}`);
},
getSourceStats() {
return this._request('GET', '/sources/stats');
},
discoverMulti(url) {
return this._request('POST', '/sources/discover-multi', { url });
},
getMyExclusions() {
return this._request('GET', '/sources/my-exclusions');
},
blockDomain(domain, notes) {
return this._request('POST', '/sources/block-domain', { domain, notes });
},
unblockDomain(domain) {
return this._request('POST', '/sources/unblock-domain', { domain });
},
deleteDomain(domain) {
return this._request('DELETE', `/sources/domain/${encodeURIComponent(domain)}`);
},
cancelRefresh(id) {
return this._request('POST', `/incidents/${id}/cancel-refresh`);
},
// Notifications
listNotifications(limit = 50) {
return this._request('GET', `/notifications?limit=${limit}`);
},
markNotificationsRead(ids = null) {
return this._request('PUT', '/notifications/mark-read', { notification_ids: ids });
},
// Subscriptions (E-Mail-Benachrichtigungen)
getSubscription(incidentId) {
return this._request('GET', '/incidents/' + incidentId + '/subscription');
},
updateSubscription(incidentId, data) {
return this._request('PUT', '/incidents/' + incidentId + '/subscription', data);
},
// Feedback
sendFeedback(data) {
return this._request('POST', '/feedback', data);
},
async sendFeedbackForm(formData) {
const token = localStorage.getItem('osint_token');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
const resp = await fetch(this.baseUrl + '/feedback', {
method: 'POST',
headers: { 'Authorization': token ? 'Bearer ' + token : '' },
body: formData,
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + resp.status);
}
},
// Export
// Tutorial-Fortschritt
getTutorialState() {
return this._request('GET', '/tutorial/state');
},
saveTutorialState(data) {
return this._request('PUT', '/tutorial/state', data);
},
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportReport(id, format, scope, sections, includeBranding, creator) {
const token = localStorage.getItem('osint_token');
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
if (sections && sections.length > 0) {
url += `&sections=${sections.join(',')}`;
} else if (scope) {
url += `&scope=${scope}`;
}
if (includeBranding === false) {
url += `&branding=off`;
}
if (creator) {
url += `&creator=${encodeURIComponent(creator)}`;
}
return fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});
},
// --- Global Admin: Org-Wechsel (herausnehmbar) ---
listOrganizations() {
return this._request('GET', '/auth/organizations');
},
switchOrg(organizationId) {
return this._request('POST', '/auth/switch-org', { organization_id: organizationId });
},
};