884 lines
40 KiB
JavaScript
884 lines
40 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — API Client
|
|
Zentraler Eingang für ALLE Backend-Kommunikation.
|
|
Kein fetch() wird außerhalb dieser Datei aufgerufen.
|
|
============================================================ */
|
|
|
|
const API = (() => {
|
|
|
|
// ----------------------------------------------------------
|
|
// Request-Deduplication: gleiche GET-URL nur einmal in-flight
|
|
// ----------------------------------------------------------
|
|
const _inflight = new Map();
|
|
|
|
// ----------------------------------------------------------
|
|
// Interner HTTP-Kern
|
|
// ----------------------------------------------------------
|
|
async function _doRequest(method, path, body, attempt) {
|
|
const config = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
};
|
|
|
|
if (body && !(body instanceof FormData)) {
|
|
config.body = JSON.stringify(body);
|
|
} else if (body instanceof FormData) {
|
|
delete config.headers['Content-Type'];
|
|
config.body = body;
|
|
}
|
|
|
|
const token = localStorage.getItem('by_token');
|
|
if (token) config.headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(`/api${path}`, config);
|
|
} catch {
|
|
// Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
|
|
if (method === 'GET' && attempt < 2) {
|
|
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
|
|
return _doRequest(method, path, body, attempt + 1);
|
|
}
|
|
const msg = 'Kein Internet — du bist offline.';
|
|
if (window.UI?.toast) UI.toast.warning(msg, 4000);
|
|
throw new APIError(msg, 0, 'network');
|
|
}
|
|
|
|
// Versions-Check: Server meldet neue Version → beim nächsten navigate() aktualisieren.
|
|
// Ausnahme: _BY_SW_RELOAD = wir sind gerade von /force-update weitergeleitet worden.
|
|
// In dem Fall ist APP_VER kurzzeitig veraltet (SW-Cache läuft noch aus) — KEIN erneuter
|
|
// Pending setzen, sonst entsteht sofort ein Loop beim nächsten Seitenwechsel.
|
|
const serverVer = response.headers.get('x-app-version');
|
|
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending && !window._BY_SW_RELOAD) {
|
|
window._byUpdatePending = true;
|
|
window._byNewVersion = serverVer;
|
|
}
|
|
|
|
if (response.status === 204) return null;
|
|
|
|
let data;
|
|
try { data = await response.json(); } catch { data = null; }
|
|
|
|
if (!response.ok) {
|
|
const _d = data?.detail;
|
|
const message = (typeof _d === 'string' ? _d
|
|
: Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe')
|
|
: null) || data?.message || `Fehler ${response.status}`;
|
|
const isSwOffline = response.status === 503 && message.startsWith('Offline');
|
|
|
|
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
|
|
if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) {
|
|
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
|
|
return _doRequest(method, path, body, attempt + 1);
|
|
}
|
|
|
|
if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000);
|
|
throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code);
|
|
}
|
|
|
|
if (data?._queued) {
|
|
if (typeof UI !== 'undefined' && UI.toast)
|
|
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
|
|
return data;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async function _request(method, path, body = null) {
|
|
// GET-Deduplication: laufende identische Anfragen zusammenfassen
|
|
if (method === 'GET') {
|
|
if (_inflight.has(path)) return _inflight.get(path);
|
|
const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path));
|
|
_inflight.set(path, promise);
|
|
return promise;
|
|
}
|
|
return _doRequest(method, path, body, 0);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Öffentliche HTTP-Methoden
|
|
// ----------------------------------------------------------
|
|
const get = (path) => _request('GET', path);
|
|
const post = (path, body) => _request('POST', path, body);
|
|
const put = (path, body) => _request('PUT', path, body);
|
|
const patch = (path, body) => _request('PATCH', path, body);
|
|
const del = (path) => _request('DELETE', path);
|
|
const upload = (path, form) => _request('POST', path, form); // FormData
|
|
|
|
// ----------------------------------------------------------
|
|
// AUTH
|
|
// ----------------------------------------------------------
|
|
const auth = {
|
|
login(email, password) {
|
|
return post('/auth/login', { email, password });
|
|
},
|
|
register(email, password, name, ref_code) {
|
|
const body = { email, password, name };
|
|
if (ref_code) body.ref_code = ref_code;
|
|
return post('/auth/register', body);
|
|
},
|
|
logout() {
|
|
localStorage.removeItem('by_token');
|
|
return post('/auth/logout');
|
|
},
|
|
me() {
|
|
return get('/auth/me');
|
|
},
|
|
referral: () => get('/auth/referral'),
|
|
upgradeRequest(tier, message) {
|
|
return post('/auth/upgrade-request', { tier, message });
|
|
},
|
|
cancelSubscription() {
|
|
return post('/auth/subscription/cancel', {});
|
|
},
|
|
selectPrimaryDog(dog_id) {
|
|
return post('/auth/subscription/select-dog', { dog_id });
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// HUNDE-PROFIL
|
|
// ----------------------------------------------------------
|
|
const dogs = {
|
|
list() { return get('/dogs'); },
|
|
get(id) { return get(`/dogs/${id}`); },
|
|
create(data) { return post('/dogs', data); },
|
|
update(id, data) { return patch(`/dogs/${id}`, data); },
|
|
delete(id) { return del(`/dogs/${id}`); },
|
|
uploadPhoto(id, formData) { return upload(`/dogs/${id}/photo`, formData); },
|
|
updatePhotoPosition(id, zoom, offsetX, offsetY) {
|
|
return patch(`/dogs/${id}/photo-position`, { zoom, offset_x: offsetX, offset_y: offsetY });
|
|
},
|
|
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
|
|
getSkills(id) { return get(`/dogs/${id}/skills`); },
|
|
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
|
|
gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); },
|
|
gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); },
|
|
futterList(id) { return get(`/dogs/${id}/futter`); },
|
|
futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); },
|
|
futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); },
|
|
reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); },
|
|
reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); },
|
|
reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); },
|
|
futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// TAGEBUCH
|
|
// ----------------------------------------------------------
|
|
const diary = {
|
|
list(dogId, params = {}) {
|
|
const q = new URLSearchParams(params).toString();
|
|
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
|
|
},
|
|
stats(dogId) { return get(`/dogs/${dogId}/diary/stats`); },
|
|
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
|
|
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
|
|
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
|
|
delete(dogId, id) { return del(`/dogs/${dogId}/diary/${id}`); },
|
|
uploadMedia(dogId, id, formData) {
|
|
return upload(`/dogs/${dogId}/diary/${id}/media`, formData);
|
|
},
|
|
deleteMedia(dogId, id) {
|
|
return del(`/dogs/${dogId}/diary/${id}/media`);
|
|
},
|
|
deleteMediaItem(dogId, entryId, mediaId) {
|
|
return del(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}`);
|
|
},
|
|
setCover(dogId, entryId, mediaId) {
|
|
return patch(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}/cover`, {});
|
|
},
|
|
nearby(dogId, lat, lon) {
|
|
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
|
|
},
|
|
locations(dogId) { return get(`/dogs/${dogId}/diary/locations`); },
|
|
calendar(dogId) { return get(`/dogs/${dogId}/diary/calendar`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// GESUNDHEIT
|
|
// ----------------------------------------------------------
|
|
const health = {
|
|
list(dogId, typ = null) {
|
|
const q = typ ? `?typ=${encodeURIComponent(typ)}` : '';
|
|
return get(`/dogs/${dogId}/health${q}`);
|
|
},
|
|
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
|
|
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
|
|
complete(dogId, id) { return post(`/dogs/${dogId}/health/${id}/erledigt`); },
|
|
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
|
|
uploadDokument(dogId, id, formData) {
|
|
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
|
|
},
|
|
deleteDocument(dogId, id) {
|
|
return del(`/dogs/${dogId}/health/${id}/dokument`);
|
|
},
|
|
uploadMedia(dogId, entryId, formData) {
|
|
return upload(`/dogs/${dogId}/health/${entryId}/media`, formData);
|
|
},
|
|
deleteMedia(dogId, entryId, mediaId) {
|
|
return del(`/dogs/${dogId}/health/${entryId}/media/${mediaId}`);
|
|
},
|
|
kiZusammenfassung(dogId) {
|
|
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
|
|
},
|
|
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
|
|
terminvorschlaege(dogId) { return get(`/dogs/${dogId}/health/terminvorschlaege`); },
|
|
symptomCheck(dogId, symptoms) {
|
|
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
|
},
|
|
gewichtVerlauf(dogId) {
|
|
return get(`/dogs/${dogId}/health/gewicht`);
|
|
},
|
|
reminders(dogId) { return get(`/dogs/${dogId}/reminders`); },
|
|
insuranceList(dogId) { return get(`/dogs/${dogId}/insurance`); },
|
|
insuranceCreate(dogId, d) { return post(`/dogs/${dogId}/insurance`, d); },
|
|
insuranceUpdate(dogId, id, d) { return patch(`/dogs/${dogId}/insurance/${id}`, d); },
|
|
insuranceDelete(dogId, id) { return del(`/dogs/${dogId}/insurance/${id}`); },
|
|
behaviorList(dogId) { return get(`/dogs/${dogId}/behavior`); },
|
|
behaviorCreate(dogId, d) { return post(`/dogs/${dogId}/behavior`, d); },
|
|
behaviorDelete(dogId, id) { return del(`/dogs/${dogId}/behavior/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// TIERÄRZTE
|
|
// ----------------------------------------------------------
|
|
const tieraerzte = {
|
|
list() { return get('/tieraerzte'); },
|
|
create(data) { return post('/tieraerzte', data); },
|
|
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
|
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
|
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
|
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
|
|
bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); },
|
|
meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); },
|
|
bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// GESUNDHEITSDOKUMENTE
|
|
// ----------------------------------------------------------
|
|
const healthDocs = {
|
|
list(dogId) { return get(`/health-docs?dog_id=${dogId}`); },
|
|
upload(formData) { return upload('/health-docs/upload', formData); },
|
|
delete(id) { return del(`/health-docs/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// GIFTKÖDER-ALARM
|
|
// ----------------------------------------------------------
|
|
const poison = {
|
|
listNearby(lat, lon, radius = 5000) {
|
|
return get(`/poison?lat=${lat}&lon=${lon}&radius=${radius}`);
|
|
},
|
|
report(data) { return post('/poison', data); },
|
|
confirm(id) { return post(`/poison/${id}/confirm`); },
|
|
resolve(id, data={}) { return post(`/poison/${id}/resolve`, data); },
|
|
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// KARTE & ORTE
|
|
// ----------------------------------------------------------
|
|
const places = {
|
|
list(typ = null) {
|
|
return get(`/places${typ ? '?typ=' + encodeURIComponent(typ) : ''}`);
|
|
},
|
|
listNearby(lat, lon, typ = null, radius = 5000) {
|
|
const params = new URLSearchParams({ lat, lon, radius });
|
|
if (typ) params.set('typ', typ);
|
|
return get(`/places?${params}`);
|
|
},
|
|
get(id) { return get(`/places/${id}`); },
|
|
create(data) { return post('/places', data); },
|
|
update(id, data) { return patch(`/places/${id}`, data); },
|
|
delete(id) { return del(`/places/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// GASSI-ROUTEN
|
|
// ----------------------------------------------------------
|
|
const routes = {
|
|
list() {
|
|
return get('/routes');
|
|
},
|
|
listNearby(lat, lon, radius = 10000) {
|
|
return get(`/routes?lat=${lat}&lon=${lon}&radius=${radius}`);
|
|
},
|
|
get(id) { return get(`/routes/${id}`); },
|
|
create(data) { return post('/routes', data); },
|
|
update(id, data) { return patch(`/routes/${id}`, data); },
|
|
trim(id, gps_track) { return patch(`/routes/${id}/trim`, { gps_track }); },
|
|
feedback(id, text) { return post(`/routes/${id}/feedback`, { text }); },
|
|
elevation(id) { return get(`/routes/${id}/elevation`); },
|
|
delete(id) { return del(`/routes/${id}`); },
|
|
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
|
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
|
|
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
|
|
updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); },
|
|
addPhoto(id, file) {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
return fetch(`/api/routes/${id}/photo`, {
|
|
method: 'POST', credentials: 'include', body: fd,
|
|
}).then(r => r.ok ? r.json() : Promise.reject(new Error('Foto-Upload fehlgeschlagen')));
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// TRAINING & ÜBUNGSFORTSCHRITT
|
|
// ----------------------------------------------------------
|
|
const training = {
|
|
getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); },
|
|
setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); },
|
|
getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); },
|
|
getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); },
|
|
setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); },
|
|
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// GASSI-TREFFEN
|
|
// ----------------------------------------------------------
|
|
const walks = {
|
|
list(lat = null, lon = null, radius = 20000) {
|
|
const params = new URLSearchParams({ radius });
|
|
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
|
|
return get(`/walks?${params}`);
|
|
},
|
|
get(id) { return get(`/walks/${id}`); },
|
|
create(data) { return post('/walks', data); },
|
|
update(id, data) { return patch(`/walks/${id}`, data); },
|
|
cancel(id) { return del(`/walks/${id}`); },
|
|
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
|
|
leave(id) { return del(`/walks/${id}/join`); },
|
|
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
|
|
inviteCandidates(id) { return get(`/walks/${id}/invite-candidates`); },
|
|
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
|
|
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
|
|
participants(id) { return get(`/walks/${id}/participants`); },
|
|
photos(id) { return get(`/walks/${id}/photos`); },
|
|
uploadPhoto(id, formData) { return upload(`/walks/${id}/photos`, formData); },
|
|
deletePhoto(id, photoId) { return del(`/walks/${id}/photos/${photoId}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// EVENTS
|
|
// ----------------------------------------------------------
|
|
const events = {
|
|
list(params = {}) {
|
|
const q = new URLSearchParams(params).toString();
|
|
return get(`/events${q ? '?' + q : ''}`);
|
|
},
|
|
get(id) { return get(`/events/${id}`); },
|
|
create(data) { return post('/events', data); },
|
|
update(id, data) { return patch(`/events/${id}`, data); },
|
|
delete(id) { return del(`/events/${id}`); },
|
|
rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); },
|
|
cancelRsvp(id) { return del(`/events/${id}/rsvp`); },
|
|
listRsvp(id) { return get(`/events/${id}/rsvp`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// SITTING
|
|
// ----------------------------------------------------------
|
|
const sitting = {
|
|
list(lat = null, lon = null, radius = 30000, service = null) {
|
|
const p = new URLSearchParams({ radius });
|
|
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
|
|
if (service) { p.set('service', service); }
|
|
return get(`/sitting?${p}`);
|
|
},
|
|
me() { return get('/sitting/me'); },
|
|
create(data) { return post('/sitting', data); },
|
|
updateMe(data) { return patch('/sitting/me', data); },
|
|
requests() { return get('/sitting/requests'); },
|
|
inbox() { return get('/sitting/inbox'); },
|
|
sendRequest(data){ return post('/sitting/requests', data); },
|
|
updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// RATINGS
|
|
// ----------------------------------------------------------
|
|
const ratings = {
|
|
rate(targetType, targetId, stars, kommentar = null) {
|
|
return post('/ratings', { target_type: targetType, target_id: targetId, stars, kommentar });
|
|
},
|
|
list(targetType, targetId) {
|
|
return get(`/ratings/${targetType}/${targetId}`);
|
|
},
|
|
mine(targetType, targetId) {
|
|
return get(`/ratings/me/${targetType}/${targetId}`);
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// FORUM
|
|
// ----------------------------------------------------------
|
|
const forum = {
|
|
threads(params = {}) {
|
|
const q = new URLSearchParams(params).toString();
|
|
return get(`/forum/threads${q ? '?' + q : ''}`);
|
|
},
|
|
thread(id) { return get(`/forum/threads/${id}`); },
|
|
create(data) { return post('/forum/threads', data); },
|
|
deleteThread(id) { return del(`/forum/threads/${id}`); },
|
|
patchThread(id, data) { return patch(`/forum/threads/${id}`, data); },
|
|
addPost(threadId, data){ return post(`/forum/threads/${threadId}/posts`, data); },
|
|
deletePost(id) { return del(`/forum/posts/${id}`); },
|
|
updateThread(id, data) { return patch(`/forum/threads/${id}/content`, data); },
|
|
updatePost(id, data) { return patch(`/forum/posts/${id}`, data); },
|
|
uploadThreadFoto(id, file) {
|
|
const fd = new FormData(); fd.append('file', file);
|
|
return upload(`/forum/threads/${id}/fotos`, fd);
|
|
},
|
|
uploadPostFoto(id, file) {
|
|
const fd = new FormData(); fd.append('file', file);
|
|
return upload(`/forum/posts/${id}/fotos`, fd);
|
|
},
|
|
like(targetType, targetId) {
|
|
return post('/forum/like', { target_type: targetType, target_id: targetId });
|
|
},
|
|
likers(targetType, targetId) {
|
|
return get(`/forum/likes/${targetType}/${targetId}`);
|
|
},
|
|
report(targetType, targetId, grund) {
|
|
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
|
|
},
|
|
reports() { return get('/forum/reports'); },
|
|
resolveReport(id) { return patch(`/forum/reports/${id}`, { resolved: 1 }); },
|
|
membersMap() { return get('/forum/members/map'); },
|
|
setLocation(lat, lon, show) {
|
|
return patch('/forum/members/location', { lat, lon, show });
|
|
},
|
|
search(q) { return get(`/forum/search?q=${encodeURIComponent(q)}`); },
|
|
|
|
// Legacy aliases (keep old names working)
|
|
listThreads(params = {}) { return forum.threads(params); },
|
|
getThread(id) { return forum.thread(id); },
|
|
createThread(data) { return forum.create(data); },
|
|
createPost(tid, data) { return forum.addPost(tid, data); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// VERLORENER HUND
|
|
// ----------------------------------------------------------
|
|
const lost = {
|
|
list(lat = null, lon = null, radius_km = 25) {
|
|
const params = new URLSearchParams({ radius_km });
|
|
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
|
|
return get(`/lost?${params}`);
|
|
},
|
|
report(data) { return post('/lost', data); },
|
|
uploadFoto(id, form) { return upload(`/lost/${id}/foto`, form); },
|
|
markFound(id) { return post(`/lost/${id}/found`); },
|
|
delete(id) { return del(`/lost/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// KNIGGE
|
|
// ----------------------------------------------------------
|
|
const knigge = {
|
|
vote: (szenario_id, answer) => post('/knigge/vote', { szenario_id, answer }),
|
|
votes: (szenario_id) => get(`/knigge/votes?szenario_id=${encodeURIComponent(szenario_id)}`),
|
|
kiRat: (situation) => post('/knigge/ki-rat', { situation }),
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// WETTER
|
|
// ----------------------------------------------------------
|
|
const weather = {
|
|
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
|
|
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
|
|
forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// FREUNDE
|
|
// ----------------------------------------------------------
|
|
const friends = {
|
|
list() { return get('/friends/'); },
|
|
pending() { return get('/friends/pending'); },
|
|
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
|
activity() { return get('/friends/activity'); },
|
|
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
|
accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); },
|
|
decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); },
|
|
remove(friendUserId) { return del(`/friends/${friendUserId}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// DIREKTNACHRICHTEN
|
|
// ----------------------------------------------------------
|
|
const chat = {
|
|
conversations() { return get('/chat/conversations'); },
|
|
start(partnerId) { return post('/chat/conversations', { partner_id: partnerId }); },
|
|
messages(convId, offset=0) { return get(`/chat/conversations/${convId}?offset=${offset}`); },
|
|
send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); },
|
|
markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); },
|
|
deleteMessage(msgId) { return del(`/chat/messages/${msgId}`); },
|
|
uploadPhoto(convId, file) {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
return upload(`/chat/conversations/${convId}/upload`, fd);
|
|
},
|
|
heartbeat() { return post('/chat/heartbeat', {}); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// PUSH NOTIFICATIONS
|
|
// ----------------------------------------------------------
|
|
const push = {
|
|
getVapidKey() { return get('/push/vapid-key'); },
|
|
subscribe(subscription) { return post('/push/subscribe', subscription); },
|
|
unsubscribe() { return del('/push/subscribe'); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// PUSH-SUBSCRIPTION HELPER
|
|
// ----------------------------------------------------------
|
|
async function subscribeToPush() {
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
|
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') return null;
|
|
|
|
const { vapid_public_key } = await push.getVapidKey();
|
|
const reg = await navigator.serviceWorker.ready;
|
|
|
|
const subscription = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: _urlBase64ToUint8Array(vapid_public_key),
|
|
});
|
|
|
|
await push.subscribe(subscription.toJSON());
|
|
return subscription;
|
|
}
|
|
|
|
function _urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(base64);
|
|
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// GEOLOCATION HELPER
|
|
// ----------------------------------------------------------
|
|
function getLocation(options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!navigator.geolocation) {
|
|
reject(new Error('Geolocation wird nicht unterstützt.'));
|
|
return;
|
|
}
|
|
navigator.geolocation.getCurrentPosition(
|
|
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
|
|
err => reject(err),
|
|
{ timeout: 8000, maximumAge: 60000, ...options }
|
|
);
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// WEBCAL
|
|
// ----------------------------------------------------------
|
|
const webcal = {
|
|
getToken: () => get('/webcal/token'),
|
|
resetToken: () => del('/webcal/token'),
|
|
};
|
|
|
|
const sharing = {
|
|
create: (dogId, role) => post(`/dogs/${dogId}/share`, { role }),
|
|
list: (dogId) => get(`/dogs/${dogId}/shares`),
|
|
revoke: (dogId, id) => del(`/dogs/${dogId}/share/${id}`),
|
|
accept: (token) => post(`/share/accept/${token}`, {}),
|
|
info: (token) => get(`/share/info/${token}`),
|
|
};
|
|
|
|
const widget = {
|
|
snapshot: () => get('/widget/snapshot'),
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// NOTIFICATIONS
|
|
// ----------------------------------------------------------
|
|
const notifications = {
|
|
list() { return get('/notifications'); },
|
|
unreadCount() { return get('/notifications/unread-count'); },
|
|
badge() { return get('/notifications/badge'); },
|
|
readAll() { return patch('/notifications/read-all', {}); },
|
|
read(id) { return patch(`/notifications/${id}/read`, {}); },
|
|
delete(id) { return del(`/notifications/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// SERVICE-ANGEBOTE (Sitting & Walks Matching)
|
|
// ----------------------------------------------------------
|
|
const services = {
|
|
list(type, lat = null, lon = null, radius = 20) {
|
|
const p = new URLSearchParams({ type, radius });
|
|
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
|
|
return get(`/services?${p}`);
|
|
},
|
|
me() { return get('/services/me'); },
|
|
upsert(data) { return post('/services', data); },
|
|
deactivate(id) { return del(`/services/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// GASTHUND-ZUGANG
|
|
// ----------------------------------------------------------
|
|
const sittingAccess = {
|
|
grant: (data) => post('/sitting-access', data),
|
|
revoke: (id) => del(`/sitting-access/${id}`),
|
|
my: () => get('/sitting-access/my'),
|
|
};
|
|
|
|
const importData = {
|
|
notestation(dogId, file) {
|
|
const fd = new FormData();
|
|
fd.append('dog_id', dogId);
|
|
fd.append('file', file);
|
|
return upload('/import/notestation', fd);
|
|
},
|
|
csv(dogId, file) {
|
|
const fd = new FormData();
|
|
fd.append('dog_id', dogId);
|
|
fd.append('file', file);
|
|
return upload('/import/csv', fd);
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// NOTIZEN
|
|
// ----------------------------------------------------------
|
|
const notes = {
|
|
get(parentType, parentId) {
|
|
return get(`/notes/${parentType}/${parentId}`);
|
|
},
|
|
getAll(params) {
|
|
return get('/notes?' + new URLSearchParams(params || {}).toString());
|
|
},
|
|
analyse() {
|
|
return post('/notes/ki-analyse', {});
|
|
},
|
|
create(parentType, parentId, data) {
|
|
return post(`/notes/${parentType}/${parentId}`, data);
|
|
},
|
|
update(id, data) {
|
|
return patch(`/notes/${id}`, data);
|
|
},
|
|
delete(id) {
|
|
return del(`/notes/${id}`);
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// ERROR-KLASSE
|
|
// ----------------------------------------------------------
|
|
class APIError extends Error {
|
|
constructor(message, status, code) {
|
|
super(message);
|
|
this.name = 'APIError';
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
// Lokale Gerätezeit als ISO-String ("2026-04-26T12:00:00") für server-seitige created_at
|
|
function clientNow() {
|
|
return new Date().toLocaleString('sv').replace(' ', 'T');
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// ZÜCHTER
|
|
// ----------------------------------------------------------
|
|
const breeder = {
|
|
status() { return get('/breeder/status'); },
|
|
apply(form) { return upload('/breeder/apply', form); },
|
|
profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); },
|
|
mapMarkers() { return get('/breeder/map'); },
|
|
updateProfile(data) { return put('/breeder/profile', data); },
|
|
adminCreateProfile() { return post('/admin/breeder/create-profile', {}); },
|
|
pendingList() { return get('/admin/breeders/pending'); },
|
|
allList() { return get('/admin/breeders'); },
|
|
documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
|
|
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
|
|
approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); },
|
|
reject(userId, grund) { return post(`/admin/breeder/${userId}/reject`, { grund }); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// WÜRFE (Züchter-Wurf-Verwaltung)
|
|
// ----------------------------------------------------------
|
|
const litters = {
|
|
// Züchter: eigene Würfe
|
|
myList() { return get('/litters/my'); },
|
|
create(data) { return post('/litters', data); },
|
|
update(id, data) { return put(`/litters/${id}`, data); },
|
|
remove(id) { return del(`/litters/${id}`); },
|
|
welfareConfirm(id) { return post(`/litters/${id}/welfare-confirm`, {}); },
|
|
// Welpen
|
|
puppies(id) { return get(`/litters/${id}/puppies`); },
|
|
addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); },
|
|
updatePuppy(id, data) { return put(`/litters/puppies/${id}`, data); },
|
|
addWeight(id, data) { return post(`/litters/puppies/${id}/weight`, data); },
|
|
// Warteliste
|
|
waitlist(id) { return get(`/litters/${id}/waitlist`); },
|
|
addWaitlist(id, data) { return post(`/litters/${id}/waitlist`, data); },
|
|
updateWaitlist(entryId, data) { return put(`/litters/waitlist/${entryId}`, data); },
|
|
removeWaitlist(entryId) { return del(`/litters/waitlist/${entryId}`); },
|
|
// Öffentlich
|
|
public(params) { return get('/litters?' + new URLSearchParams(params || {}).toString()); },
|
|
detail(id) { return get(`/litters/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// LÄUFIGKEIT & TRÄCHTIGKEIT
|
|
// ----------------------------------------------------------
|
|
const laeufi = {
|
|
list(hundId) { return get(`/laeufi/${hundId}`); },
|
|
add(hundId, data) { return post(`/laeufi/${hundId}`, data); },
|
|
update(id, data) { return put(`/laeufi/entry/${id}`, data); },
|
|
remove(id) { return del(`/laeufi/entry/${id}`); },
|
|
// Progesterontests
|
|
listProg(laeufiId) { return get(`/laeufi/entry/${laeufiId}/prog`); },
|
|
addProg(laeufiId, data) { return post(`/laeufi/entry/${laeufiId}/prog`, data); },
|
|
updateProg(id, data) { return put(`/laeufi/prog/${id}`, data); },
|
|
removeProg(id) { return del(`/laeufi/prog/${id}`); },
|
|
// Deckdaten
|
|
listDeck(hundId) { return get(`/laeufi/deck/${hundId}`); },
|
|
addDeck(hundId, data) { return post(`/laeufi/deck/${hundId}`, data); },
|
|
updateDeck(id, data) { return put(`/laeufi/deck/entry/${id}`, data); },
|
|
removeDeck(id) { return del(`/laeufi/deck/entry/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// ZÜCHTER-FOTOS
|
|
// ----------------------------------------------------------
|
|
const breederPhotos = {
|
|
upload(form) { return upload('/breeder/photos/upload', form); },
|
|
list(entityType, entityId) { return get(`/photos/${entityType}/${entityId}`); },
|
|
updateVisibility(id, visibility) { return patch(`/breeder/photos/${id}/visibility`, { visibility }); },
|
|
setPrimary(id) { return patch(`/breeder/photos/${id}/primary`, {}); },
|
|
updateCaption(id, caption) { return patch(`/breeder/photos/${id}/caption`, { caption }); },
|
|
remove(id) { return del(`/breeder/photos/${id}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// ZUCHTKARTEI (Hunde-Stammdaten, Gesundheit, Genetik, Titel)
|
|
// ----------------------------------------------------------
|
|
const zuchthunde = {
|
|
list() { return get('/zuchthunde'); },
|
|
get(id) { return get(`/zuchthunde/${id}`); },
|
|
create(data) { return post('/zuchthunde', data); },
|
|
update(id, data) { return put(`/zuchthunde/${id}`, data); },
|
|
remove(id) { return del(`/zuchthunde/${id}`); },
|
|
pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); },
|
|
healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); },
|
|
addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); },
|
|
updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); },
|
|
deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); },
|
|
geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); },
|
|
addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); },
|
|
updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); },
|
|
deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); },
|
|
titles(id) { return get(`/zuchthunde/${id}/titles`); },
|
|
addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); },
|
|
updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); },
|
|
deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); },
|
|
trialMating(vaterId, mutterId) { return post('/zuchthunde/trial-mating', { vater_id: vaterId, mutter_id: mutterId }); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// ZÜCHTER-KI
|
|
// ----------------------------------------------------------
|
|
const zuchtKi = {
|
|
wurfankuendigung(litterId) { return post('/zucht-ki/wurfankuendigung', { litter_id: litterId }); },
|
|
genetikErklaerung(litterId, ziel) { return post('/zucht-ki/genetik-erklaerung', { litter_id: litterId, zielgruppe: ziel }); },
|
|
paarungAnalyse(vaterId, mutterId, ik, welfareLevel) {
|
|
return post('/zucht-ki/paarung-analyse', { vater_id: vaterId, mutter_id: mutterId, ik_prozent: ik, welfare_level: welfareLevel });
|
|
},
|
|
hundBeschreibung(hundId) { return post('/zucht-ki/hund-beschreibung', { hund_id: hundId }); },
|
|
jahresbericht() { return post('/zucht-ki/jahresbericht', {}); },
|
|
jahresberichtList() { return get('/zucht-ki/jahresbericht'); },
|
|
jahresberichtGet(id) { return get(`/zucht-ki/jahresbericht/${id}`); },
|
|
};
|
|
|
|
const osm = {
|
|
pois: (type, south, west, north, east) =>
|
|
get(`/osm/pois?type=${type}&south=${south}&west=${west}&north=${north}&east=${east}&fast=true`),
|
|
};
|
|
|
|
// SW-Cache-Einträge für eine URL löschen (z.B. nach Foto-Upload)
|
|
async function swCacheDelete(path) {
|
|
try {
|
|
const c = await caches.open('ban-yaro-api-v1');
|
|
await c.delete(new Request(path));
|
|
} catch {}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// BILD-KOMPRESSION (Client-Side vor Upload)
|
|
// ----------------------------------------------------------
|
|
// iPhone-Fotos sind 4-12 MB — vor Upload auf max. 2000px / JPEG q=0.85
|
|
// skalieren. HEIC/HEIF unverändert lassen (Browser-Canvas kann sie nicht
|
|
// decoden, Backend hat eigene HEIC-Konvertierung). Nicht-Bilder unverändert.
|
|
async function compressImage(file, maxSize = 2000, quality = 0.85) {
|
|
try {
|
|
if (!file || !(file instanceof File || file instanceof Blob)) return file;
|
|
const type = (file.type || '').toLowerCase();
|
|
if (!type.startsWith('image/')) return file;
|
|
if (type === 'image/heic' || type === 'image/heif') return file;
|
|
if (type === 'image/gif') return file; // GIF-Animation nicht kaputt skalieren
|
|
if (file.size < 500_000) return file; // <500KB: lohnt sich nicht
|
|
|
|
const img = await createImageBitmap(file);
|
|
try {
|
|
const longest = Math.max(img.width, img.height);
|
|
const scale = Math.min(1, maxSize / longest);
|
|
// Nur skalieren wenn Bild wirklich größer ist; bei scale=1 trotzdem als JPEG
|
|
// neu kodieren — spart bei iPhone-Originalen oft trotzdem viel (EXIF, weniger Qualität).
|
|
const w = Math.round(img.width * scale);
|
|
const h = Math.round(img.height * scale);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w; canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return file;
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
|
|
const blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', quality));
|
|
if (!blob || blob.size >= file.size) return file; // Kompression hat nichts gebracht
|
|
|
|
const newName = (file.name || 'photo.jpg').replace(/\.(heic|heif|png|webp|jpeg|jpg)$/i, '.jpg');
|
|
const finalName = /\.jpe?g$/i.test(newName) ? newName : (newName + '.jpg');
|
|
return new File([blob], finalName, { type: 'image/jpeg', lastModified: Date.now() });
|
|
} finally {
|
|
// ImageBitmap-Ressourcen freigeben (wo unterstützt)
|
|
if (typeof img.close === 'function') img.close();
|
|
}
|
|
} catch {
|
|
// Bei jedem Fehler (z.B. createImageBitmap auf HEIC) — original zurück
|
|
return file;
|
|
}
|
|
}
|
|
// Auch global verfügbar, damit Seiten-Module ihn direkt nutzen können
|
|
if (typeof window !== 'undefined') window.compressImage = compressImage;
|
|
|
|
// Öffentliche API
|
|
return {
|
|
get, post, put, patch, del, upload, swCacheDelete, compressImage,
|
|
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
|
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
|
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
|
breeder, litters, breederPhotos, zuchthunde, zuchtKi, laeufi,
|
|
osm,
|
|
subscribeToPush, getLocation, clientNow,
|
|
APIError,
|
|
};
|
|
|
|
})();
|