baustelle-pwa/lib/api.js
Eduard Wisch a108664854 App ohne Netz lesbar statt "Failed to fetch"
Gemeldet: die App zeigte beim Oeffnen nur "Failed to fetch", waehrend das
Teilen aus WhatsApp weiter ging. Deploy und API waren in Ordnung — die
Meldung ist die rohe Browserausgabe eines fetch(), das gar nicht bis zum
Server kam, und stand 1:1 im UI, weil die Routen im catch e.message
anzeigten. Das Teilen fiel nicht auf, weil share.html sich die letzten
Auftraege seit eaad796 lokal merkt; die Hauptapp hatte fuer die Listen
keinen Offline-Fallback.

- api.js uebersetzt Netzfehler in einen eigenen Fehler mit offline=true
  und deutschem Text; 4xx/5xx bleiben Serverfehler
- isTransient() in offline.js erkannte Netzfehler an "instanceof TypeError".
  Ohne Anpassung waere ein Foto aus dem Funkloch als dauerhaft
  fehlgeschlagen gewertet und nach sechs Versuchen in Quarantaene gelandet
- Datenspiegel in IndexedDB: Auftrags-, Kunden- und Berichtsliste sowie
  geoeffnete Details (letzte 30). Ohne Netz wird der Stand angezeigt,
  mit Band "Kein Netz — Stand von HH:MM Uhr", und die Suche filtert darin
- beim Abmelden wird der Spiegel geloescht (geteiltes Geraet)
- sw.js: der Same-Origin-Zweig endete auf caches.match(); fand der Cache
  nichts, wurde respondWith(undefined) aufgerufen und die Anfrage
  scheiterte erneut mit genau der Meldung, die verhindert werden sollte.
  Jetzt immer eine echte Response, nur vollstaendige Antworten im Cache
- Precache nach Deploy: die Seite meldet dem Worker ihre echten Asset-URLs,
  sonst ist der Cache direkt nach einem Deploy leer (?v=<mtime>)

Muster aus KB #1037 und #1041. Verifiziert auf der lokalen Testinstanz
inklusive echtem Funkloch (setOffline + reload): App startet aus dem
Cache, zeigt Band und Auftragsliste, keine JS-Fehler.
2026-08-31 19:07:09 +02:00

424 lines
17 KiB
JavaScript

/* API-Client für die Bericht-REST-API */
(function () {
// API-Base wird aus der aktuellen Origin gebaut.
// PWA läuft unter /baustelle/, API unter /custom/bericht/api/
const API_BASE = window.location.origin + '/custom/bericht/api';
let cachedToken = null;
async function getToken() {
if (cachedToken) return cachedToken;
cachedToken = await idb.get('jwt');
return cachedToken;
}
async function setToken(t) {
cachedToken = t;
await idb.set('jwt', t);
}
async function clearToken() {
cachedToken = null;
await idb.del('jwt');
await idb.del('user');
}
async function request(path, opts = {}) {
const headers = opts.headers || {};
// Same-origin-CSRF-Schutz von awlauth verlangt diesen Header. Vanilla fetch
// setzt ihn — anders als jQuery — NICHT automatisch; ohne ihn: 403.
if (!headers['X-Requested-With']) headers['X-Requested-With'] = 'XMLHttpRequest';
if (!headers['Content-Type'] && !(opts.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
// Optionaler Timeout: bricht hängende Requests ab, statt ewig zu warten
// (schwaches/totes Netz auf der Baustelle → Foto landet zuverlässig in der Queue)
let signal = opts.signal;
let toHandle = null;
if (opts.timeoutMs && typeof AbortController !== 'undefined') {
const ctrl = new AbortController();
signal = ctrl.signal;
toHandle = setTimeout(() => { try { ctrl.abort(); } catch (_) {} }, opts.timeoutMs);
}
let r;
try {
r = await fetch(API_BASE + path, { ...opts, headers, signal, credentials: 'same-origin' });
} catch (err) {
// Hier ist die Anfrage GAR NICHT bis zum Server gekommen: kein Netz, Funkloch,
// abgebrochene Verbindung, Zeitueberschreitung. fetch() wirft dafuer einen
// TypeError mit der englischen Rohmeldung "Failed to fetch" — die stand bis
// 31.08.2026 wortwoertlich in der App, weil die Routen `e.message` anzeigen.
//
// Wir uebersetzen das in einen eigenen Fehler mit `offline = true`. Daran
// erkennen die Routen, dass sie den Offline-Spiegel zeigen duerfen, statt eine
// Fehlermeldung — ein Serverfehler (4xx/5xx) traegt die Markierung NICHT.
//
// `name` wird bewusst mitgefuehrt: isTransient() in offline.js entscheidet
// daran (und an `offline`), ob ein Foto in der Upload-Queue bleibt. Ginge die
// Information verloren, landete jedes Foto aus dem Funkloch nach sechs
// Versuchen in der Quarantaene statt hochgeladen zu werden.
const abort = !!(err && err.name === 'AbortError');
const e = new Error(abort
? 'Zeitueberschreitung — der Server hat nicht rechtzeitig geantwortet'
: 'Keine Verbindung zum Server');
e.name = abort ? 'AbortError' : 'NetworkError';
e.offline = true;
e.cause = err;
throw e;
} finally {
if (toHandle) clearTimeout(toHandle);
}
if (r.status === 401) {
await clearToken();
window.location.hash = '#/login';
const e = new Error('Nicht authentifiziert'); e.status = 401; throw e;
}
// Antwort MUSS JSON sein. Ein 2xx mit HTML (z.B. Apache/Proxy-Login- oder
// Wartungsseite bei abgelaufener Session, ErrorDocument 200) darf NIEMALS als
// Erfolg gelten — sonst würde ein Foto fälschlich als hochgeladen aus der Queue
// gelöscht und wäre verloren.
const ct = (r.headers.get('Content-Type') || '').toLowerCase();
let data = {};
if (ct.includes('application/json')) {
data = await r.json().catch(() => ({}));
} else if (r.ok) {
const e = new Error('Unerwartete Server-Antwort (kein JSON)');
e.status = r.status; e.nonJson = true;
throw e;
}
if (!r.ok) { const e = new Error(data.error || 'API-Fehler'); e.status = r.status; throw e; }
return data;
}
async function login(loginName, password) {
const r = await request('/auth.php', {
method: 'POST',
body: JSON.stringify({ login: loginName, password }),
});
// Kein Token mehr im Body — die Session steckt im HttpOnly-Cookie awl_sso.
await idb.set('user', r.user);
return r;
}
async function logout() {
// Server-Logout: löscht das awl_sso-Cookie (Single-Logout über alle AWL-Apps).
try { await request('/logout.php', { method: 'POST' }); } catch (_) {}
await clearToken();
}
async function listCustomers(opts = {}) {
const params = new URLSearchParams();
if (opts.q) params.set('q', opts.q);
const qs = params.toString();
return request('/customers.php' + (qs ? '?' + qs : ''));
}
async function getCustomer(id) {
return request('/customers.php?id=' + id);
}
async function listOrders(opts = {}) {
const params = new URLSearchParams();
if (opts.q) params.set('q', opts.q);
if (opts.open) params.set('open', '1');
const qs = params.toString();
return request('/orders.php' + (qs ? '?' + qs : ''));
}
// Fotoeinstellungen (Admin → Bericht-Modul): Verkleinern ja/nein, JPEG-Qualitaet
async function getConfig() {
return request('/config.php');
}
async function getOrder(id) {
return request('/orders.php?id=' + id);
}
async function listOrderPhotos(id) {
return request('/orders.php?id=' + id + '&action=photos');
}
// Semantik: at-least-once. Geht die Server-Antwort NACH dem Speichern verloren
// (z.B. Netz-Abbruch, Proxy-2xx-HTML), gilt der Upload als unbestätigt und wird
// wiederholt. Der Server vergleicht seit Bericht 1.6.0 den Inhalt (md5) gegen die
// Dateien im Auftragsordner und legt eine identische Datei kein zweites Mal ab —
// er antwortet dann mit duplicate:true und dem relpath der vorhandenen Datei.
// Die Wiederholung ist damit idempotent statt duplikaterzeugend; die Regel „lieber
// ein Duplikat als ein verlorenes Foto" gilt weiterhin als Rückfallebene.
async function uploadOrderPhoto(orderId, fileBlob, filename) {
const fd = new FormData();
fd.append('file', fileBlob, filename || 'photo.jpg');
const res = await request('/orders.php?id=' + orderId + '&action=upload_photo', {
method: 'POST',
body: fd,
timeoutMs: 45000,
});
// Der Server MUSS einen relpath liefern, wenn er die Datei wirklich gespeichert hat.
// Fehlt er, gilt der Upload als NICHT bestätigt → Foto bleibt in der Queue (Retry).
if (!res || typeof res.relpath !== 'string' || res.relpath === '') {
const e = new Error('Upload nicht bestätigt (kein relpath)');
e.uploadUnconfirmed = true;
throw e;
}
return res;
}
async function createOrder(payload) {
return request('/orders.php?action=create', {
method: 'POST',
body: JSON.stringify(payload),
});
}
async function getReport(id) {
return request('/reports.php?id=' + id);
}
async function listReports() {
return request('/reports.php');
}
async function createReport(opts) {
return request('/reports.php?action=create', {
method: 'POST',
body: JSON.stringify(opts),
});
}
async function listTemplates() {
return request('/templates.php');
}
async function listOdtTemplates() {
return request('/odt_templates.php');
}
async function finalizeReport(id) {
return request('/reports.php?id=' + id + '&action=finalize', { method: 'POST' });
}
async function deleteReport(id) {
return request('/reports.php?id=' + id, { method: 'DELETE' });
}
async function deletePhoto(relpath) {
return request('/delete_photo.php', {
method: 'POST',
body: JSON.stringify({ relpath }),
});
}
async function uploadVoiceNote(orderId, audioBlob, filename) {
const fd = new FormData();
fd.append('file', audioBlob, filename || 'voice.webm');
return request('/voice.php?order_id=' + orderId, { method: 'POST', body: fd });
}
async function transcribeAudio(relpath) {
return request('/transcribe.php', {
method: 'POST',
body: JSON.stringify({ relpath }),
});
}
async function listMaterials(elementType, elementId) {
return request('/materials.php?element_type=' + elementType + '&element_id=' + elementId);
}
async function addMaterial(elementType, elementId, data) {
return request('/materials.php?element_type=' + elementType + '&element_id=' + elementId, {
method: 'POST',
body: JSON.stringify(data),
});
}
async function deleteMaterial(id) {
return request('/materials.php?id=' + id + '&delete=1', { method: 'POST' });
}
async function uploadAnnotatedPhoto(orderId, fileBlob, filename) {
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
return uploadOrderPhoto(orderId, fileBlob, filename);
}
/* Textnotizen — eigener Endpoint wie die Sprachnotiz, damit die Datei als
* notiz_<betreff>_<datum>.txt im Auftragsordner landet. */
async function listNotes(orderId) {
return request('/note.php?order_id=' + orderId);
}
async function getNote(orderId, file) {
return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file));
}
/** file weglassen → neue Notiz, file angeben → bestehende ändern */
async function saveNote(orderId, data, file) {
const qs = '/note.php?order_id=' + orderId + (file ? '&file=' + encodeURIComponent(file) : '');
return request(qs, { method: 'POST', body: JSON.stringify(data), timeoutMs: 45000 });
}
async function deleteNote(orderId, file) {
return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file) + '&delete=1',
{ method: 'POST' });
}
async function deletePage(pageId) {
return request('/pages.php?id=' + pageId, { method: 'DELETE' });
}
async function updatePageNote(pageId, note) {
return request('/pages.php?id=' + pageId, {
method: 'POST',
body: JSON.stringify({ note }),
});
}
async function uploadSignature(berichtId, pngBlob, opts) {
const fd = new FormData();
fd.append('file', pngBlob, 'signature.png');
if (opts && opts.signer_name) fd.append('signer_name', opts.signer_name);
if (opts && opts.gps_lat != null) fd.append('gps_lat', opts.gps_lat);
if (opts && opts.gps_lon != null) fd.append('gps_lon', opts.gps_lon);
return request('/pages.php?action=signature&bericht_id=' + berichtId, {
method: 'POST',
body: fd,
});
}
/* ----- Lieferungen ----- */
async function listShipments(orderId) {
return request('/shipments.php?order_id=' + orderId);
}
async function getShipment(id) {
return request('/shipments.php?id=' + id);
}
async function getShipmentPdfBlobUrl(shipmentId) {
const params = new URLSearchParams({ id: shipmentId, action: 'pdf' });
// shipments.php nutzt awlauth_require (CSRF) -> X-Requested-With nötig.
// Per fetch setzbar (kein window.location für das Shipment-PDF).
const r = await fetch(API_BASE + '/shipments.php?' + params.toString(), {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!r.ok) return null;
const blob = await r.blob();
return { url: URL.createObjectURL(blob), blob };
}
async function confirmShipment(shipmentId, pngBlob, opts) {
const fd = new FormData();
fd.append('file', pngBlob, 'signature.png');
fd.append('signer_name', (opts && opts.signer_name) || '');
if (opts && opts.gps_lat != null) fd.append('gps_lat', opts.gps_lat);
if (opts && opts.gps_lon != null) fd.append('gps_lon', opts.gps_lon);
return request('/shipments.php?id=' + shipmentId + '&action=confirm', {
method: 'POST',
body: fd,
});
}
async function reorderPages(pageIds) {
return request('/pages.php?action=reorder', {
method: 'POST',
body: JSON.stringify({ order: pageIds }),
});
}
async function getPdfBlobUrl(berichtId) {
// pdf.php nutzt awlauth_verify (kein CSRF) — Cookie reicht.
const params = new URLSearchParams({ id: berichtId });
const r = await fetch(API_BASE + '/pdf.php?' + params.toString(), { credentials: 'same-origin' });
if (!r.ok) return null;
const blob = await r.blob();
return URL.createObjectURL(blob);
}
/**
* Direkte URL auf ein serverseitig erzeugtes Thumbnail — für <img src="…">.
*
* Bewusst KEIN fetch→Blob: über die echte URL greifen HTTP-Cache (ETag, 30 Tage) und
* loading="lazy", und der Browser lädt die Kacheln parallel. Der Blob-Weg lädt jede
* Datei komplett, bevor das <img> überhaupt existiert — bei 30 Baustellenfotos war das
* der Grund, warum die Fotoliste im Mobilfunknetz so lange stand.
* Auth läuft über das awl_sso-Cookie, das der Browser bei same-origin mitschickt.
*/
function photoThumbUrl(relpath, w) {
const params = new URLSearchParams({ relpath, size: 'thumb', w: String(w || 320) });
return API_BASE + '/photo.php?' + params.toString();
}
/** Direkte URL auf die Originaldatei — für Vollbild-<img>, Audio-Player, Download. */
function photoUrl(relpath) {
return API_BASE + '/photo.php?' + new URLSearchParams({ relpath }).toString();
}
/**
* Lädt eine Bild-Datei von der API als Blob-URL.
* Auth über das awl_sso-Cookie (same-origin) — photo.php nutzt awlauth_verify.
*/
const blobUrlCache = new Map();
async function getPhotoBlobUrl(relpath, size) {
const key = (size || 'full') + '|' + relpath;
if (blobUrlCache.has(key)) return blobUrlCache.get(key);
const params = new URLSearchParams({ relpath });
if (size) params.set('size', size);
const r = await fetch(API_BASE + '/photo.php?' + params.toString(), { credentials: 'same-origin' });
if (!r.ok) {
const body = await r.text().catch(() => '');
console.warn('photo.php failed', r.status, relpath, body);
return null;
}
const ct = r.headers.get('Content-Type') || '';
if (!ct.startsWith('image/')) {
const body = await r.text().catch(() => '');
console.warn('photo.php not an image', ct, body);
return null;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
blobUrlCache.set(key, url);
return url;
}
function clearPhotoCache() {
for (const url of blobUrlCache.values()) URL.revokeObjectURL(url);
blobUrlCache.clear();
}
// Lädt eine beliebige Datei (PDF, DOCX, ...) als Blob-URL. Auth per awl_sso-Cookie.
// Ohne Mime-Filter — Aufrufer entscheidet selbst, was damit passiert.
async function getFileBlobUrl(relpath) {
const params = new URLSearchParams({ relpath });
const url = API_BASE + '/photo.php?' + params.toString();
console.log('[API] Fetching file:', relpath);
const r = await fetch(url, { credentials: 'same-origin' });
if (!r.ok) {
console.warn('[API] File fetch failed', r.status, relpath);
return null;
}
const blob = await r.blob();
console.log('[API] Got blob:', blob.size, 'bytes, type:', blob.type);
return { url: URL.createObjectURL(blob), blob, mime: r.headers.get('Content-Type') || '' };
}
// Low-level request-Funktion auch exposen für Spezialfälle
window.api = {
request,
getToken, setToken, clearToken,
login, logout,
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, createOrder,
listCustomers, getCustomer,
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
listNotes, getNote, saveNote, deleteNote,
listMaterials, addMaterial, deleteMaterial,
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
getConfig,
};
})();