Karte (map.js):
- OSM Overpass API: Restaurants, Tierärzte, Parkplätze, Bänke, Wasserstellen
- Leaflet.markercluster für alle OSM-Layer
- Standort-Dot mit GPS-Genauigkeitskreis, Wake-Lock bei Aufzeichnung
- Community-Pins setzen/löschen, Meldungen, Crosshair-Placement
- Layer-Sichtbarkeit in localStorage (by_map_visible_v1)
Routen (routes.js + routen.py):
- Komoot-Stil: SVG-Track-Preview, Foto-Upload, Nearby-POIs im Detail-Modal
- Neue Felder: is_public, hunde_tauglichkeit, foto_urls
- Rate-Endpoint (POST /api/routes/{id}/rate)
- Foto-Upload (POST /api/routes/{id}/photo)
- Fix: json_extract $[-1] → $[#-1] (SQLite-kompatibler Pfad für letztes Element)
Backend (osm.py, database.py, scheduler.py):
- /api/osm/pois: OSM-Overpass-Cache mit Tile-Logik (14 Tage TTL)
- /api/osm/user-poi: Community-Marker CRUD
- /api/osm/report: Marker als ungültig melden
- Neue Tabellen: osm_pois, osm_tiles, user_map_pois, osm_reports
- Giftköder-Archiv-Job (täglich 03:00, soft-delete nach Ablauf)
- Giftköder-Archiv-Job als APScheduler-CronJob
UI: Orte-Menüpunkt entfernt (in Karte integriert), APP_VER auf 62
333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — API Client
|
|
Zentraler Eingang für ALLE Backend-Kommunikation.
|
|
Kein fetch() wird außerhalb dieser Datei aufgerufen.
|
|
============================================================ */
|
|
|
|
const API = (() => {
|
|
|
|
// ----------------------------------------------------------
|
|
// Interner HTTP-Kern
|
|
// ----------------------------------------------------------
|
|
async function _request(method, path, body = null, options = {}) {
|
|
const config = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
|
|
};
|
|
|
|
if (body && !(body instanceof FormData)) {
|
|
config.body = JSON.stringify(body);
|
|
} else if (body instanceof FormData) {
|
|
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
|
|
config.body = body;
|
|
}
|
|
|
|
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
|
|
const token = localStorage.getItem('by_token');
|
|
if (token) {
|
|
config.headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(`/api${path}`, config);
|
|
} catch (err) {
|
|
throw new APIError('Keine Verbindung zum Server.', 0, 'network');
|
|
}
|
|
|
|
// 204 No Content
|
|
if (response.status === 204) return null;
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = data?.detail || data?.message || `Fehler ${response.status}`;
|
|
throw new APIError(message, response.status, data?.code);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Ö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) {
|
|
return post('/auth/register', { email, password, name });
|
|
},
|
|
logout() {
|
|
localStorage.removeItem('by_token');
|
|
return post('/auth/logout');
|
|
},
|
|
me() {
|
|
return get('/auth/me');
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// TAGEBUCH
|
|
// ----------------------------------------------------------
|
|
const diary = {
|
|
list(dogId, params = {}) {
|
|
const q = new URLSearchParams(params).toString();
|
|
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
|
|
},
|
|
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);
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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); },
|
|
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
|
|
uploadDokument(dogId, id, formData) {
|
|
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
|
|
},
|
|
kiZusammenfassung(dogId) {
|
|
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
|
|
},
|
|
symptomCheck(dogId, symptoms) {
|
|
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// TIERÄRZTE
|
|
// ----------------------------------------------------------
|
|
const tieraerzte = {
|
|
list() { return get('/tieraerzte'); },
|
|
create(data) { return post('/tieraerzte', data); },
|
|
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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); },
|
|
delete(id) { return del(`/routes/${id}`); },
|
|
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
|
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')));
|
|
},
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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 }); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// WETTER
|
|
// ----------------------------------------------------------
|
|
const weather = {
|
|
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// 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 }
|
|
);
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// ERROR-KLASSE
|
|
// ----------------------------------------------------------
|
|
class APIError extends Error {
|
|
constructor(message, status, code) {
|
|
super(message);
|
|
this.name = 'APIError';
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
// Öffentliche API
|
|
return {
|
|
get, post, put, patch, del, upload,
|
|
auth, dogs, diary, health, tieraerzte, poison,
|
|
places, routes, walks, events, sitting, weather, push,
|
|
subscribeToPush, getLocation,
|
|
APIError,
|
|
};
|
|
|
|
})();
|