All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Drei Sachen, die zusammengehoeren — alle drei Ursachen dafuer, dass Fotos
fehlten oder nichts drauf zu erkennen war.
1. BACKGROUND SYNC (sw.js)
Nachliefern lief bisher NUR in der offenen Seite: online-Ereignis,
„wieder sichtbar", 15-Sekunden-Takt. Display aus oder App weggewischt =
eingefrorene Timer, es passierte nichts. Der Service Worker haengt sich
jetzt an dieselbe IndexedDB-Queue und laedt selbst hoch; die Anmeldung
steckt im HttpOnly-Cookie awl_sso und geht bei same-origin automatisch mit,
also braucht es dort kein Token-Handling.
- geloescht wird nur bei bestaetigtem Upload: 2xx UND application/json UND
relpath. 2xx mit HTML (Proxy/abgelaufene Anmeldung) gilt als Fehlschlag
- bleibt etwas offen, wird das Sync-Versprechen abgelehnt -> der Browser
stellt erneut zu
- Erfolg meldet der SW per postMessage an die App (Badge/Miniaturen)
- periodicSync zusaetzlich, falls der Browser ihn gewaehrt
2. VOLLE KAMERAQUALITAET (app.js)
Ausgeloest wird jetzt ueber ImageCapture.takePhoto() — das liefert die
native JPEG-Datei der Kamera in Sensoraufloesung samt EXIF, statt ein
Standbild aus dem Video-Stream abzugreifen. Kann der Browser das nicht,
bleibt das Standbild als Rueckfallebene (dann mit Qualitaet 0.95 statt 0.92).
Verkleinert wird nur noch, wenn es im Admin eingestellt ist (Vorgabe: nein).
3. PERSIST-FIRST STRENG (app.js)
Das Foto geht unveraendert und als allererstes in die Queue; Verkleinern
passiert erst DANACH und ersetzt den Eintrag nur bei Erfolg
(offline.replaceQueuedBlob). Vorher lag das Verkleinern davor — und weil es
bei ausgeschaltetem Display haengen bleibt, war das Foto in dem Moment
nirgends gesichert. Genau so gingen am 28.08. acht Fotos verloren. Jetzt
kann nach dem Sichern nichts mehr passieren, was ein Foto kostet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
402 lines
16 KiB
JavaScript
402 lines
16 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' });
|
|
} 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,
|
|
};
|
|
})();
|