feat: KoePi Filial-Filter jetzt ueber echte marktguru Store-ID-Whitelist, Adresse direkt von der API
This commit is contained in:
@@ -650,4 +650,15 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// KöPi: Whitelist der exakt gewünschten Filialen (echte marktguru Store-IDs,
|
||||||
|
// aufgelöst aus bestätigten Prospekt-Links — siehe koepi/routes.js)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS koepi_local_stores (
|
||||||
|
store_id INTEGER PRIMARY KEY,
|
||||||
|
retailer TEXT NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
resolved_at DATETIME
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -17,71 +17,43 @@ const MG_SETTINGS = encodeURIComponent(JSON.stringify({
|
|||||||
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
|
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Gewünschte Händler ────────────────────────────────────────────────────────
|
// ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─
|
||||||
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut'];
|
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut'];
|
||||||
const RETAILER_ADDRESSES = {
|
|
||||||
'edeka': 'Düsseldorfer Landstr. 361',
|
|
||||||
'e center': 'Düsseldorfer Landstr. 361',
|
|
||||||
'rewe': 'Mündelheimer Str. 132',
|
|
||||||
'netto': 'Im Bonnefeld 19-21',
|
|
||||||
'kaufland': 'Auf der Höhe 20',
|
|
||||||
'penny': 'Sittardsberger Allee 10',
|
|
||||||
'trinkgut': 'Keniastraße 39',
|
|
||||||
};
|
|
||||||
function isTargetRetailer(name) {
|
function isTargetRetailer(name) {
|
||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
const n = name.toLowerCase();
|
const n = name.toLowerCase();
|
||||||
return TARGET_RETAILERS.some(r => n.includes(r));
|
return TARGET_RETAILERS.some(r => n.includes(r));
|
||||||
}
|
}
|
||||||
function getAddress(retailer) {
|
|
||||||
if (!retailer) return '';
|
// Die exakt gewünschten Filialen, als Prospekt-Link vom Nutzer bestätigt.
|
||||||
const n = retailer.toLowerCase();
|
// Wird EINMALIG (oder per Admin-Knopf erneut) aufgelöst: aus jedem Leaflet wird
|
||||||
for (const [key, addr] of Object.entries(RETAILER_ADDRESSES)) {
|
// per closestStore die echte, stabile Store-ID ermittelt und in der DB als
|
||||||
if (n.includes(key)) return addr;
|
// Whitelist gespeichert. Nur Angebote, deren aufgelöste Store-ID hier drin ist,
|
||||||
}
|
// werden angezeigt — keine Adress-Rätselei mehr nötig.
|
||||||
return '';
|
const LOCAL_STORE_SEED_LEAFLETS = [
|
||||||
|
{ retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19
|
||||||
|
{ retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21
|
||||||
|
{ retailer: 'trinkgut', leafletId: 5634593 }, // Keniastraße 39
|
||||||
|
{ retailer: 'REWE', leafletId: 5616164 }, // Fischerstr. 110-112
|
||||||
|
{ retailer: 'Kaufland', leafletId: 5628827 }, // Auf der Höhe 20
|
||||||
|
{ retailer: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10
|
||||||
|
{ retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361
|
||||||
|
];
|
||||||
|
|
||||||
|
function isKnownLocalStore(storeId) {
|
||||||
|
if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken
|
||||||
|
return !!db.prepare('SELECT 1 FROM koepi_local_stores WHERE store_id=?').get(storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.",
|
// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Filiale —
|
||||||
// Leerzeichen/Punkte egal — Hausnummer bleibt Teil des Vergleichs)
|
// zusätzliches Sicherheitsnetz, greift aber durch die ID-Whitelist ohnehin kaum
|
||||||
function normalizeAddress(s) {
|
// noch (REWE Dortmund taucht dort schlicht nicht auf)
|
||||||
return (s || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/straße|strasse/g, 'str')
|
|
||||||
.replace(/[^a-zäöüß0-9]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Adresse —
|
|
||||||
// "REWE Dortmund" ist auf marktguru ein eigener Franchise-Betreiber-Name, der
|
|
||||||
// auch dann raus soll, wenn seine Adresse zufällig mit einer lokalen Filiale
|
|
||||||
// übereinstimmt
|
|
||||||
const EXCLUDED_RETAILER_NAMES = ['rewe dortmund'];
|
const EXCLUDED_RETAILER_NAMES = ['rewe dortmund'];
|
||||||
function isExcludedRetailer(retailer) {
|
function isExcludedRetailer(retailer) {
|
||||||
const n = (retailer || '').toLowerCase();
|
const n = (retailer || '').toLowerCase();
|
||||||
return EXCLUDED_RETAILER_NAMES.some(x => n.includes(x));
|
return EXCLUDED_RETAILER_NAMES.some(x => n.includes(x));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüft, ob die tatsächlich gescrapte Adresse zu einer der bekannten LOKALEN
|
|
||||||
// Filialen für diesen Händler passt. Ohne erkannten Händler oder ohne gescrapte
|
|
||||||
// Adresse wird NICHT gefiltert (lieber ein Angebot zuviel zeigen als eins zu
|
|
||||||
// Unrecht verstecken, falls marktguru das Layout mal ändert).
|
|
||||||
//
|
|
||||||
// STAND JETZT: Die verwendete Such-URL (/search/...?zipCode=...) liefert pro
|
|
||||||
// Angebot KEINE Adresse, daher ist diese Funktion aktuell praktisch immer
|
|
||||||
// "true" (kein echter Filter-Effekt). Bleibt drin für den Fall, dass sich das
|
|
||||||
// Seiten-Layout künftig ändert oder eine andere URL genutzt wird, die
|
|
||||||
// Adressen liefert (getestet: /bl/{marke}/{ort} tut das, bringt aber einen
|
|
||||||
// anderen/kleineren Angebots-Pool mit — siehe Git-Historie).
|
|
||||||
function isLocalBranch(retailer, realAddress) {
|
|
||||||
const n = (retailer || '').toLowerCase();
|
|
||||||
const key = Object.keys(RETAILER_ADDRESSES).find(k => n.includes(k));
|
|
||||||
if (!key) return true;
|
|
||||||
if (!realAddress) return true;
|
|
||||||
const expectedParts = RETAILER_ADDRESSES[key].split('/').map(p => normalizeAddress(p.trim()));
|
|
||||||
const real = normalizeAddress(realAddress);
|
|
||||||
return expectedParts.some(p => real.includes(p) || (p && p.includes(real)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Angebote von marktguru ────────────────────────────────────────────────────
|
// ── Angebote von marktguru ────────────────────────────────────────────────────
|
||||||
// Primär über die öffentlich bekannte marktguru-JSON-API (schnell, kein Browser
|
// Primär über die öffentlich bekannte marktguru-JSON-API (schnell, kein Browser
|
||||||
// nötig, kein Cloudflare-Risiko). Schlägt das fehl (z.B. weil die Keys mal
|
// nötig, kein Cloudflare-Risiko). Schlägt das fehl (z.B. weil die Keys mal
|
||||||
@@ -101,36 +73,120 @@ function formatIsoDate(iso) {
|
|||||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
|
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOffersViaApi() {
|
// Duisburg 47259 Zentrum — für den closestStore-Abgleich im leaflets-Detail-Call.
|
||||||
const url = `${MARKTGURU_API_URL}?as=web&limit=24&offset=0&q=${encodeURIComponent(MG_QUERY)}&zipCode=${MG_ZIP}`;
|
// Innerhalb der zipCode-Filterung spielt der genaue Punkt kaum eine Rolle.
|
||||||
const res = await fetch(url, {
|
const MG_LAT = '51.3543213';
|
||||||
headers: {
|
const MG_LON = '6.7151937';
|
||||||
|
|
||||||
|
const mgHeaders = {
|
||||||
'x-clientkey': MARKTGURU_CLIENT_KEY,
|
'x-clientkey': MARKTGURU_CLIENT_KEY,
|
||||||
'x-apikey': MARKTGURU_API_KEY,
|
'x-apikey': MARKTGURU_API_KEY,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
},
|
};
|
||||||
});
|
|
||||||
|
// Holt für eine PLZ alle laufenden Prospekt-Kampagnen (leafletFlights) und baut
|
||||||
|
// eine Zuordnung leafletFlightId -> mainLeafletId, die wir brauchen, um darüber
|
||||||
|
// später das leaflet-Detail (inkl. closestStore) abzurufen.
|
||||||
|
async function fetchLeafletFlightToLeafletMap() {
|
||||||
|
const url = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
|
||||||
|
const res = await fetch(url, { headers: mgHeaders });
|
||||||
|
if (!res.ok) throw new Error(`leafletflights HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
const map = new Map();
|
||||||
|
for (const f of (json.results || [])) {
|
||||||
|
if (f.id && f.mainLeafletId) map.set(f.id, f.mainLeafletId);
|
||||||
|
}
|
||||||
|
console.log(`🍺 KöPi: ${map.size} leafletFlight→Leaflet Zuordnungen geladen (von ${json.totalResults} gesamt)`);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holt für ein einzelnes Leaflet die closestStore-Adresse — das ist die
|
||||||
|
// eigentliche, bisher fehlende Information, um Filialen derselben Kette zu
|
||||||
|
// unterscheiden (z.B. "Netto Im Bonnefeld" vs. "Netto Hochstraße 10").
|
||||||
|
// Holt für ein einzelnes Leaflet den kompletten closestStore-Datensatz von
|
||||||
|
// marktguru (id, address, name, distanceInMeters) — die Store-ID ist die
|
||||||
|
// stabile, exakte Grundlage für die Filial-Whitelist.
|
||||||
|
async function fetchClosestStore(leafletId, cache) {
|
||||||
|
if (cache.has(leafletId)) return cache.get(leafletId);
|
||||||
|
const empty = { id: null, address: '', name: '', distanceMeters: null };
|
||||||
|
try {
|
||||||
|
const url = `https://api.marktguru.de/api/v1/leaflets/${leafletId}?as=mobiledetailed&latitude=${MG_LAT}&longitude=${MG_LON}&zipCode=${MG_ZIP}`;
|
||||||
|
const res = await fetch(url, { headers: mgHeaders });
|
||||||
|
if (!res.ok) { cache.set(leafletId, empty); return empty; }
|
||||||
|
const json = await res.json();
|
||||||
|
const store = json.closestStore;
|
||||||
|
const result = store
|
||||||
|
? { id: store.id ?? null, address: store.address || '', name: store.name || '', distanceMeters: store.distanceInMeters ?? null }
|
||||||
|
: empty;
|
||||||
|
console.log(`🍺 KöPi: Leaflet ${leafletId} → closestStore: ${store ? `id=${store.id} "${store.address}" (${store.name}, ${Math.round(store.distanceInMeters||0)}m entfernt)` : 'keine'}`);
|
||||||
|
cache.set(leafletId, result);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`🍺 KöPi: closestStore-Abruf für Leaflet ${leafletId} fehlgeschlagen: ${e.message}`);
|
||||||
|
cache.set(leafletId, empty);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig
|
||||||
|
// zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim
|
||||||
|
// ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann
|
||||||
|
// über den Admin-Button jederzeit erneut angestoßen werden, falls sich mal ein
|
||||||
|
// Prospekt-Link ändert oder eine Filiale ergänzt werden soll.
|
||||||
|
async function resolveLocalStores() {
|
||||||
|
const cache = new Map();
|
||||||
|
let resolved = 0;
|
||||||
|
for (const seed of LOCAL_STORE_SEED_LEAFLETS) {
|
||||||
|
const store = await fetchClosestStore(seed.leafletId, cache);
|
||||||
|
if (store.id != null) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||||
|
`).run(store.id, seed.retailer, store.address);
|
||||||
|
console.log(`🍺 KöPi: Filiale aufgelöst — ${seed.retailer} → Store-ID ${store.id} ("${store.address}")`);
|
||||||
|
resolved++;
|
||||||
|
} else {
|
||||||
|
console.log(`🍺 KöPi: Filiale NICHT auflösbar — ${seed.retailer} (Leaflet ${seed.leafletId}) — evtl. Prospekt abgelaufen, Link erneuern`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOffersViaApi() {
|
||||||
|
const url = `${MARKTGURU_API_URL}?as=web&limit=24&offset=0&q=${encodeURIComponent(MG_QUERY)}&zipCode=${MG_ZIP}`;
|
||||||
|
const res = await fetch(url, { headers: mgHeaders });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(`API HTTP ${res.status}: ${text.slice(0, 200)}`);
|
throw new Error(`API HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|
||||||
console.log('🍺 KöPi API: totalResults=', json.totalResults, '| results.length=', json.results?.length);
|
console.log('🍺 KöPi API: totalResults=', json.totalResults, '| results.length=', json.results?.length);
|
||||||
if (json.results?.[0]) {
|
|
||||||
(json.results || []).forEach((r, i) => {
|
const rawOffers = json.results || [];
|
||||||
console.log(`🍺 KöPi API Rohdaten [${i}] (${r.advertisers?.[0]?.name || '?'}, € ${r.price}):`, JSON.stringify(r));
|
|
||||||
});
|
// Echte Filial-ID + Adresse pro Angebot auflösen (leafletFlightId -> mainLeafletId -> closestStore)
|
||||||
|
let flightToLeaflet = new Map();
|
||||||
|
try {
|
||||||
|
flightToLeaflet = await fetchLeafletFlightToLeafletMap();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('🍺 KöPi: leafletflights-Abruf fehlgeschlagen, Filial-Filter bleibt diesmal wirkungslos:', e.message);
|
||||||
|
}
|
||||||
|
const leafletCache = new Map();
|
||||||
|
const storeInfoByOfferId = new Map();
|
||||||
|
for (const r of rawOffers) {
|
||||||
|
const leafletId = flightToLeaflet.get(r.leafletFlightId);
|
||||||
|
if (leafletId) {
|
||||||
|
const info = await fetchClosestStore(leafletId, leafletCache);
|
||||||
|
storeInfoByOfferId.set(r.id, info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (json.results || []).map(r => {
|
return rawOffers.map(r => {
|
||||||
const advertiser = r.advertisers?.[0] || {};
|
const advertiser = r.advertisers?.[0] || {};
|
||||||
const store = r.closestStore || r.store || {};
|
|
||||||
const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price);
|
const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price);
|
||||||
const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice);
|
const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice);
|
||||||
const realAddress = [store.street, store.streetNumber].filter(Boolean).join(' ').trim()
|
const storeInfo = storeInfoByOfferId.get(r.id) || { id: null, address: '', distanceMeters: null };
|
||||||
|| store.address || advertiser.address || '';
|
|
||||||
const validity0 = r.validityDates?.[0] || {};
|
const validity0 = r.validityDates?.[0] || {};
|
||||||
return {
|
return {
|
||||||
name: r.description || r.title || advertiser.name || '',
|
name: r.description || r.title || advertiser.name || '',
|
||||||
@@ -142,8 +198,11 @@ async function fetchOffersViaApi() {
|
|||||||
validity: '',
|
validity: '',
|
||||||
description: r.description || '',
|
description: r.description || '',
|
||||||
badge: '',
|
badge: '',
|
||||||
address: '',
|
// Adresse kommt jetzt direkt von marktguru (closestStore.address), nicht mehr fest hinterlegt
|
||||||
realAddress,
|
address: storeInfo.address || '',
|
||||||
|
realAddress: storeInfo.address || '',
|
||||||
|
storeId: storeInfo.id,
|
||||||
|
storeDistanceMeters: storeInfo.distanceMeters,
|
||||||
leafletFlightId: r.leafletFlightId || null,
|
leafletFlightId: r.leafletFlightId || null,
|
||||||
offerId: r.id || null,
|
offerId: r.id || null,
|
||||||
image: r.image?.url || r.images?.[0]?.url || null,
|
image: r.image?.url || r.images?.[0]?.url || null,
|
||||||
@@ -259,19 +318,24 @@ function processOffers(offers) {
|
|||||||
console.log('🍺 KöPi: Angebote per Namens-Filter ausgeschlossen:', excludedNow.map(o => o.retailer).join(', '));
|
console.log('🍺 KöPi: Angebote per Namens-Filter ausgeschlossen:', excludedNow.map(o => o.retailer).join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('🍺 KöPi Filial-Whitelist-Diagnose:', filtered.map(o =>
|
||||||
|
`${o.retailer} | Store-ID=${o.storeId ?? 'unbekannt'} | ${o.realAddress||'keine Adresse'} | bekannt=${isKnownLocalStore(o.storeId)}`
|
||||||
|
).join('\n '));
|
||||||
|
|
||||||
const localOnly = filtered
|
const localOnly = filtered
|
||||||
.filter(o => !isExcludedRetailer(o.retailer))
|
.filter(o => !isExcludedRetailer(o.retailer))
|
||||||
.filter(o => isLocalBranch(o.retailer, o.realAddress));
|
.filter(o => isKnownLocalStore(o.storeId));
|
||||||
|
|
||||||
for (const o of localOnly) {
|
for (const o of localOnly) {
|
||||||
o.retailerDisplay = o.retailer;
|
o.retailerDisplay = o.retailer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplizieren: gleicher normalisierter Händler + gleicher Preis → ein Eintrag
|
// Deduplizieren: gleicher normalisierter Händler + gleicher Preis + gleiche
|
||||||
|
// Filiale → ein Eintrag
|
||||||
const merged = new Map();
|
const merged = new Map();
|
||||||
for (const o of localOnly) {
|
for (const o of localOnly) {
|
||||||
const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || '';
|
const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || '';
|
||||||
const key = `${normalizedRetailer}|${o.price}`;
|
const key = `${normalizedRetailer}|${o.price}|${o.storeId ?? ''}`;
|
||||||
if (merged.has(key)) {
|
if (merged.has(key)) {
|
||||||
const existing = merged.get(key);
|
const existing = merged.get(key);
|
||||||
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
|
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
|
||||||
@@ -281,7 +345,7 @@ function processOffers(offers) {
|
|||||||
merged.set(key, { ...o });
|
merged.set(key, { ...o });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...merged.values()].map(o => ({ ...o, address: getAddress(o.retailer) }));
|
return [...merged.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrapeMarktguru(forceRefresh = false) {
|
async function scrapeMarktguru(forceRefresh = false) {
|
||||||
@@ -291,6 +355,14 @@ async function scrapeMarktguru(forceRefresh = false) {
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filial-Whitelist beim allerersten Lauf automatisch auflösen (Tabelle leer)
|
||||||
|
const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n;
|
||||||
|
if (knownCount === 0) {
|
||||||
|
console.log('🍺 KöPi: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...');
|
||||||
|
try { await resolveLocalStores(); }
|
||||||
|
catch (e) { console.error('🍺 KöPi: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
let offers, source;
|
let offers, source;
|
||||||
try {
|
try {
|
||||||
offers = await fetchOffersViaApi();
|
offers = await fetchOffersViaApi();
|
||||||
@@ -530,6 +602,24 @@ router.post('/clear-cache', authenticate, (req, res) => {
|
|||||||
|
|
||||||
// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte
|
// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte
|
||||||
// Änderung, aber NUR an den Admin selbst (nicht an alle Nutzer)
|
// Änderung, aber NUR an den Admin selbst (nicht an alle Nutzer)
|
||||||
|
// GET /local-stores – aktuelle Filial-Whitelist anzeigen (Admin)
|
||||||
|
router.get('/local-stores', authenticate, (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all();
|
||||||
|
res.json({ stores, seedCount: LOCAL_STORE_SEED_LEAFLETS.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /local-stores/resolve – Filial-Whitelist neu auflösen (Admin, z.B. wenn
|
||||||
|
// sich ein Prospekt-Link geändert hat oder eine Filiale ergänzt wurde)
|
||||||
|
router.post('/local-stores/resolve', authenticate, async (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
try {
|
||||||
|
const resolved = await resolveLocalStores();
|
||||||
|
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all();
|
||||||
|
res.json({ ok: true, resolved, total: LOCAL_STORE_SEED_LEAFLETS.length, stores });
|
||||||
|
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/run-daily-check', authenticate, async (req, res) => {
|
router.post('/run-daily-check', authenticate, async (req, res) => {
|
||||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user