aktueller stand
This commit is contained in:
97
server.js
97
server.js
@@ -14,6 +14,7 @@ const notificationService = require('./services/notificationService');
|
|||||||
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
||||||
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
||||||
const requestLogStore = require('./services/requestLogStore');
|
const requestLogStore = require('./services/requestLogStore');
|
||||||
|
const { withSessionRetry } = require('./services/sessionRefresh');
|
||||||
const {
|
const {
|
||||||
getStoreStatus: getCachedStoreStatusEntry,
|
getStoreStatus: getCachedStoreStatusEntry,
|
||||||
setStoreStatus: setCachedStoreStatusEntry,
|
setStoreStatus: setCachedStoreStatusEntry,
|
||||||
@@ -23,6 +24,7 @@ const {
|
|||||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
|
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
|
||||||
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
||||||
|
const PROFILE_DETAILS_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
@@ -106,6 +108,33 @@ function isAdmin(profile) {
|
|||||||
return profile.email.toLowerCase() === adminEmail;
|
return profile.email.toLowerCase() === adminEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchProfileWithCache(session, { force = false } = {}) {
|
||||||
|
if (!session?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cached = session.profileDetailsCache;
|
||||||
|
const isFresh = cached?.fetchedAt && Date.now() - cached.fetchedAt <= PROFILE_DETAILS_TTL_MS;
|
||||||
|
if (!force && isFresh && cached?.data) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const details = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchProfile(session.cookieHeader, { throwOnError: true }),
|
||||||
|
{ label: 'fetchProfile' }
|
||||||
|
);
|
||||||
|
sessionStore.update(session.id, {
|
||||||
|
profileDetailsCache: { data: details, fetchedAt: Date.now() }
|
||||||
|
});
|
||||||
|
return details;
|
||||||
|
} catch (error) {
|
||||||
|
if (cached?.data) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleWithCurrentSettings(sessionId, config) {
|
function scheduleWithCurrentSettings(sessionId, config) {
|
||||||
const settings = adminConfig.readSettings();
|
const settings = adminConfig.readSettings();
|
||||||
scheduleConfig(sessionId, config, settings);
|
scheduleConfig(sessionId, config, settings);
|
||||||
@@ -222,7 +251,7 @@ async function ensureStoreLocationIndex(session, { force = false } = {}) {
|
|||||||
if (!force && storeLocationIndex.size > 0 && fresh) {
|
if (!force && storeLocationIndex.size > 0 && fresh) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const details = await foodsharingClient.fetchProfile(session.cookieHeader);
|
const details = await fetchProfileWithCache(session);
|
||||||
const regions = Array.isArray(details?.regions)
|
const regions = Array.isArray(details?.regions)
|
||||||
? details.regions.filter((region) => Number(region?.classification) === 1)
|
? details.regions.filter((region) => Number(region?.classification) === 1)
|
||||||
: [];
|
: [];
|
||||||
@@ -232,7 +261,11 @@ async function ensureStoreLocationIndex(session, { force = false } = {}) {
|
|||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
let payload = getCachedRegionStores(region.id);
|
let payload = getCachedRegionStores(region.id);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
const result = await foodsharingClient.fetchRegionStores(region.id, session.cookieHeader);
|
const result = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchRegionStores(region.id, session.cookieHeader),
|
||||||
|
{ label: 'fetchRegionStores' }
|
||||||
|
);
|
||||||
payload = {
|
payload = {
|
||||||
total: Number(result?.total) || 0,
|
total: Number(result?.total) || 0,
|
||||||
stores: Array.isArray(result?.stores) ? result.stores : []
|
stores: Array.isArray(result?.stores) ? result.stores : []
|
||||||
@@ -319,10 +352,10 @@ async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new M
|
|||||||
|
|
||||||
async function refreshStoreStatus(
|
async function refreshStoreStatus(
|
||||||
storeIds = [],
|
storeIds = [],
|
||||||
cookieHeader,
|
session,
|
||||||
{ force = false, storeInfoMap = new Map() } = {}
|
{ force = false, storeInfoMap = new Map() } = {}
|
||||||
) {
|
) {
|
||||||
if (!Array.isArray(storeIds) || storeIds.length === 0 || !cookieHeader) {
|
if (!Array.isArray(storeIds) || storeIds.length === 0 || !session?.cookieHeader) {
|
||||||
return { refreshed: 0, changes: [] };
|
return { refreshed: 0, changes: [] };
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -336,7 +369,11 @@ async function refreshStoreStatus(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const details = await foodsharingClient.fetchStoreDetails(storeId, cookieHeader);
|
const details = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader),
|
||||||
|
{ label: 'fetchStoreDetails' }
|
||||||
|
);
|
||||||
const status = Number(details?.teamSearchStatus);
|
const status = Number(details?.teamSearchStatus);
|
||||||
const normalized = Number.isFinite(status) ? status : null;
|
const normalized = Number.isFinite(status) ? status : null;
|
||||||
const previous = entry ? entry.teamSearchStatus : null;
|
const previous = entry ? entry.teamSearchStatus : null;
|
||||||
@@ -366,7 +403,7 @@ async function refreshStoreStatus(
|
|||||||
return { refreshed, changes };
|
return { refreshed, changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichStoresWithTeamStatus(stores = [], cookieHeader, { forceRefresh = false } = {}) {
|
async function enrichStoresWithTeamStatus(stores = [], session, { forceRefresh = false } = {}) {
|
||||||
if (!Array.isArray(stores) || stores.length === 0) {
|
if (!Array.isArray(stores) || stores.length === 0) {
|
||||||
return { stores, statusMeta: { total: 0, refreshed: 0, fromCache: 0, missing: 0 } };
|
return { stores, statusMeta: { total: 0, refreshed: 0, fromCache: 0, missing: 0 } };
|
||||||
}
|
}
|
||||||
@@ -392,7 +429,7 @@ async function enrichStoresWithTeamStatus(stores = [], cookieHeader, { forceRefr
|
|||||||
let refreshed = 0;
|
let refreshed = 0;
|
||||||
let changes = [];
|
let changes = [];
|
||||||
if (staleIds.length > 0) {
|
if (staleIds.length > 0) {
|
||||||
const result = await refreshStoreStatus(staleIds, cookieHeader, {
|
const result = await refreshStoreStatus(staleIds, session, {
|
||||||
force: forceRefresh,
|
force: forceRefresh,
|
||||||
storeInfoMap
|
storeInfoMap
|
||||||
});
|
});
|
||||||
@@ -502,14 +539,19 @@ async function runStoreRefreshJob(session, job) {
|
|||||||
job.status = 'running';
|
job.status = 'running';
|
||||||
job.startedAt = Date.now();
|
job.startedAt = Date.now();
|
||||||
const settings = adminConfig.readSettings();
|
const settings = adminConfig.readSettings();
|
||||||
const stores = await foodsharingClient.fetchStores(session.cookieHeader, session.profile.id, {
|
const stores = await withSessionRetry(
|
||||||
delayBetweenRequestsMs: settings.storePickupCheckDelayMs,
|
session,
|
||||||
onStoreCheck: (store, processed, total) => {
|
() =>
|
||||||
job.processed = processed;
|
foodsharingClient.fetchStores(session.cookieHeader, session.profile.id, {
|
||||||
job.total = total;
|
delayBetweenRequestsMs: settings.storePickupCheckDelayMs,
|
||||||
job.currentStore = store.name || `Store ${store.id}`;
|
onStoreCheck: (store, processed, total) => {
|
||||||
}
|
job.processed = processed;
|
||||||
});
|
job.total = total;
|
||||||
|
job.currentStore = store.name || `Store ${store.id}`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ label: 'fetchStores' }
|
||||||
|
);
|
||||||
job.processed = stores.length;
|
job.processed = stores.length;
|
||||||
job.total = stores.length;
|
job.total = stores.length;
|
||||||
job.currentStore = null;
|
job.currentStore = null;
|
||||||
@@ -726,10 +768,15 @@ app.get('/api/auth/session', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/profile', requireAuth, async (req, res) => {
|
app.get('/api/profile', requireAuth, async (req, res) => {
|
||||||
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
|
try {
|
||||||
res.json({
|
const details = await fetchProfileWithCache(req.session);
|
||||||
profile: details || req.session.profile
|
res.json({
|
||||||
});
|
profile: details || req.session.profile
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PROFILE] Profil konnte nicht geladen werden:', error.message);
|
||||||
|
res.status(500).json({ error: 'Profil konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/location/nearest-store', requireAuth, async (req, res) => {
|
app.get('/api/location/nearest-store', requireAuth, async (req, res) => {
|
||||||
@@ -750,7 +797,7 @@ app.get('/api/location/nearest-store', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
|
app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
|
const details = await fetchProfileWithCache(req.session);
|
||||||
const regions = Array.isArray(details?.regions)
|
const regions = Array.isArray(details?.regions)
|
||||||
? details.regions.filter((region) => Number(region?.classification) === 1)
|
? details.regions.filter((region) => Number(region?.classification) === 1)
|
||||||
: [];
|
: [];
|
||||||
@@ -779,7 +826,11 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re
|
|||||||
|
|
||||||
if (!basePayload) {
|
if (!basePayload) {
|
||||||
try {
|
try {
|
||||||
const result = await foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader);
|
const result = await withSessionRetry(
|
||||||
|
req.session,
|
||||||
|
() => foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader),
|
||||||
|
{ label: 'fetchRegionStores' }
|
||||||
|
);
|
||||||
basePayload = {
|
basePayload = {
|
||||||
total: Number(result?.total) || 0,
|
total: Number(result?.total) || 0,
|
||||||
stores: Array.isArray(result?.stores) ? result.stores : []
|
stores: Array.isArray(result?.stores) ? result.stores : []
|
||||||
@@ -799,7 +850,7 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re
|
|||||||
|
|
||||||
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
|
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
|
||||||
filteredStores,
|
filteredStores,
|
||||||
req.session.cookieHeader,
|
req.session,
|
||||||
{ forceRefresh: forceStatusRefresh }
|
{ forceRefresh: forceStatusRefresh }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -863,7 +914,7 @@ app.get('/api/user/preferences', requireAuth, async (req, res) => {
|
|||||||
const preferences = readPreferences(req.session.profile.id);
|
const preferences = readPreferences(req.session.profile.id);
|
||||||
let location = preferences.location;
|
let location = preferences.location;
|
||||||
try {
|
try {
|
||||||
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
|
const details = await fetchProfileWithCache(req.session);
|
||||||
const coords = details?.coordinates;
|
const coords = details?.coordinates;
|
||||||
const sanitized = sanitizeLocation({ lat: coords?.lat, lon: coords?.lon });
|
const sanitized = sanitizeLocation({ lat: coords?.lat, lon: coords?.lon });
|
||||||
if (sanitized) {
|
if (sanitized) {
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
botToken: ''
|
botToken: '',
|
||||||
|
chatId: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -84,7 +85,8 @@ function sanitizeNotifications(input = {}) {
|
|||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
enabled: !!(input?.telegram?.enabled),
|
enabled: !!(input?.telegram?.enabled),
|
||||||
botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken)
|
botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken),
|
||||||
|
chatId: sanitizeString(input?.telegram?.chatId || defaults.telegram.chatId)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,15 +55,43 @@ client.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const CSRF_COOKIE_NAMES = ['CSRF_TOKEN', 'CSRF-TOKEN', 'XSRF-TOKEN', 'XSRF_TOKEN'];
|
||||||
|
|
||||||
|
function extractCookieValue(cookies = [], name) {
|
||||||
|
if (!Array.isArray(cookies) || !name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const match = cookies.find((cookie) => cookie.startsWith(prefix));
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return match.split(';')[0].slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
function extractCsrfToken(cookies = []) {
|
function extractCsrfToken(cookies = []) {
|
||||||
if (!Array.isArray(cookies)) {
|
for (const name of CSRF_COOKIE_NAMES) {
|
||||||
|
const value = extractCookieValue(cookies, name);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCsrfTokenFromCookieHeader(cookieHeader = '') {
|
||||||
|
if (!cookieHeader) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const tokenCookie = cookies.find((cookie) => cookie.startsWith('CSRF_TOKEN='));
|
const pairs = cookieHeader.split(';').map((part) => part.trim());
|
||||||
if (!tokenCookie) {
|
for (const name of CSRF_COOKIE_NAMES) {
|
||||||
return null;
|
const prefix = `${name}=`;
|
||||||
|
const match = pairs.find((pair) => pair.startsWith(prefix));
|
||||||
|
if (match) {
|
||||||
|
return match.slice(prefix.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tokenCookie.split(';')[0].split('=')[1];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeCookies(cookies = []) {
|
function serializeCookies(cookies = []) {
|
||||||
@@ -78,8 +106,10 @@ function buildHeaders(cookieHeader, csrfToken) {
|
|||||||
if (cookieHeader) {
|
if (cookieHeader) {
|
||||||
headers.cookie = cookieHeader;
|
headers.cookie = cookieHeader;
|
||||||
}
|
}
|
||||||
if (csrfToken) {
|
const token = csrfToken || extractCsrfTokenFromCookieHeader(cookieHeader);
|
||||||
headers['x-csrf-token'] = csrfToken;
|
if (token) {
|
||||||
|
headers['x-csrf-token'] = token;
|
||||||
|
headers['x-xsrf-token'] = token;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
@@ -135,25 +165,13 @@ async function login(email, password) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkSession(cookieHeader, profileId) {
|
async function fetchProfile(cookieHeader, { throwOnError = false } = {}) {
|
||||||
if (!cookieHeader) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.get(`/api/wall/foodsaver/${profileId}?limit=1`, {
|
|
||||||
headers: buildHeaders(cookieHeader)
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProfile(cookieHeader) {
|
|
||||||
try {
|
try {
|
||||||
return await getCurrentUserDetails(cookieHeader);
|
return await getCurrentUserDetails(cookieHeader);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (throwOnError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
console.warn('Profil konnte nicht geladen werden:', error.message);
|
console.warn('Profil konnte nicht geladen werden:', error.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -296,7 +314,6 @@ async function bookSlot(storeId, utcDate, profileId, session) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
login,
|
login,
|
||||||
checkSession,
|
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
fetchStores,
|
fetchStores,
|
||||||
fetchPickups,
|
fetchPickups,
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ async function sendTelegramNotification(adminTelegram, userTelegram, payload) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendAdminTelegramNotification(payload) {
|
||||||
|
const adminSettings = adminConfig.readSettings();
|
||||||
|
const adminTelegram = adminSettings.notifications?.telegram;
|
||||||
|
if (!adminTelegram?.enabled || !adminTelegram.botToken || !adminTelegram.chatId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendTelegramNotification(
|
||||||
|
adminTelegram,
|
||||||
|
{ enabled: true, chatId: adminTelegram.chatId },
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyChannels(profileId, template) {
|
async function notifyChannels(profileId, template) {
|
||||||
const adminSettings = adminConfig.readSettings();
|
const adminSettings = adminConfig.readSettings();
|
||||||
const userSettings = readNotificationSettings(profileId);
|
const userSettings = readNotificationSettings(profileId);
|
||||||
@@ -217,11 +230,32 @@ async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonL
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendAdminBookingErrorNotification({ profileId, profileEmail, storeName, storeId, pickupDate, error }) {
|
||||||
|
const dateLabel = formatDateLabel(pickupDate);
|
||||||
|
const storeLabel = storeName || storeId || 'Unbekannter Store';
|
||||||
|
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
|
||||||
|
const profileLabel = profileEmail || profileId || 'Unbekanntes Profil';
|
||||||
|
const messageLines = [
|
||||||
|
`Buchung fehlgeschlagen für ${storeLabel} am ${dateLabel}.`,
|
||||||
|
`Profil: ${profileLabel}.`,
|
||||||
|
`Fehler: ${error || 'Unbekannter Fehler'}.`
|
||||||
|
];
|
||||||
|
if (storeLink) {
|
||||||
|
messageLines.push(storeLink);
|
||||||
|
}
|
||||||
|
await sendAdminTelegramNotification({
|
||||||
|
title: 'Fehler beim Slot-Buchen',
|
||||||
|
message: messageLines.join('\n'),
|
||||||
|
priority: 'high'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sendSlotNotification,
|
sendSlotNotification,
|
||||||
sendStoreWatchNotification,
|
sendStoreWatchNotification,
|
||||||
sendStoreWatchSummaryNotification,
|
sendStoreWatchSummaryNotification,
|
||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
sendDesiredWindowMissedNotification,
|
sendDesiredWindowMissedNotification,
|
||||||
sendDormantPickupWarning
|
sendDormantPickupWarning,
|
||||||
|
sendAdminBookingErrorNotification
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { readConfig, writeConfig } = require('./configStore');
|
|||||||
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
||||||
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
||||||
const { sendDormantPickupWarning } = require('./notificationService');
|
const { sendDormantPickupWarning } = require('./notificationService');
|
||||||
|
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
||||||
|
|
||||||
function wait(ms) {
|
function wait(ms) {
|
||||||
if (!ms || ms <= 0) {
|
if (!ms || ms <= 0) {
|
||||||
@@ -122,43 +123,6 @@ function persistEntryDeactivation(profileId, entryId, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSession(session) {
|
|
||||||
const profileId = session.profile?.id;
|
|
||||||
if (!profileId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stillValid = await foodsharingClient.checkSession(session.cookieHeader, profileId);
|
|
||||||
if (stillValid) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.credentials) {
|
|
||||||
console.warn(`Session ${session.id} kann nicht erneuert werden – keine Zugangsdaten gespeichert.`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const refreshed = await foodsharingClient.login(
|
|
||||||
session.credentials.email,
|
|
||||||
session.credentials.password
|
|
||||||
);
|
|
||||||
sessionStore.update(session.id, {
|
|
||||||
cookieHeader: refreshed.cookieHeader,
|
|
||||||
csrfToken: refreshed.csrfToken,
|
|
||||||
profile: {
|
|
||||||
...session.profile,
|
|
||||||
...refreshed.profile
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(`Session ${session.id} wurde erfolgreich erneuert.`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Session ${session.id} konnte nicht erneuert werden:`, error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDateValue(input) {
|
function toDateValue(input) {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return null;
|
return null;
|
||||||
@@ -326,12 +290,20 @@ async function processBooking(session, entry, pickup) {
|
|||||||
|
|
||||||
const utcDate = new Date(pickup.date).toISOString();
|
const utcDate = new Date(pickup.date).toISOString();
|
||||||
try {
|
try {
|
||||||
const allowed = await foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session);
|
const allowed = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session),
|
||||||
|
{ label: 'pickupRuleCheck' }
|
||||||
|
);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`);
|
console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session);
|
await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session),
|
||||||
|
{ label: 'bookSlot' }
|
||||||
|
);
|
||||||
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
||||||
await notificationService.sendSlotNotification({
|
await notificationService.sendSlotNotification({
|
||||||
profileId: session.profile.id,
|
profileId: session.profile.id,
|
||||||
@@ -345,6 +317,21 @@ async function processBooking(session, entry, pickup) {
|
|||||||
persistEntryDeactivation(session.profile.id, entry.id);
|
persistEntryDeactivation(session.profile.id, entry.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
|
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
|
||||||
|
try {
|
||||||
|
await notificationService.sendAdminBookingErrorNotification({
|
||||||
|
profileId: session.profile.id,
|
||||||
|
profileEmail: session.profile.email,
|
||||||
|
storeName,
|
||||||
|
storeId: entry.id,
|
||||||
|
pickupDate: pickup.date,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error(
|
||||||
|
`[NOTIFY] Admin-Benachrichtigung für fehlgeschlagene Buchung bei ${storeName} fehlgeschlagen:`,
|
||||||
|
notifyError.message
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +355,11 @@ async function checkEntry(sessionId, entry, settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pickups = await foodsharingClient.fetchPickups(entry.id, session.cookieHeader);
|
const pickups = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchPickups(entry.id, session.cookieHeader),
|
||||||
|
{ label: 'fetchPickups' }
|
||||||
|
);
|
||||||
let hasProfileId = false;
|
let hasProfileId = false;
|
||||||
let availablePickup = null;
|
let availablePickup = null;
|
||||||
|
|
||||||
@@ -435,7 +426,11 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
|||||||
for (let index = 0; index < watchers.length; index += 1) {
|
for (let index = 0; index < watchers.length; index += 1) {
|
||||||
const watcher = watchers[index];
|
const watcher = watchers[index];
|
||||||
try {
|
try {
|
||||||
const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader);
|
const details = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader),
|
||||||
|
{ label: 'fetchStoreDetails' }
|
||||||
|
);
|
||||||
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
||||||
const checkedAt = Date.now();
|
const checkedAt = Date.now();
|
||||||
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
||||||
@@ -589,6 +584,15 @@ function setMonthOffset(date, offset) {
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMissingLastPickupStoreIds(config = []) {
|
||||||
|
if (!Array.isArray(config)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
.filter((entry) => entry && entry.id && !entry.hidden && !entry.skipDormantCheck && !entry.lastPickupAt)
|
||||||
|
.map((entry) => String(entry.id));
|
||||||
|
}
|
||||||
|
|
||||||
async function checkDormantMembers(sessionId) {
|
async function checkDormantMembers(sessionId) {
|
||||||
const session = sessionStore.get(sessionId);
|
const session = sessionStore.get(sessionId);
|
||||||
if (!session?.profile?.id) {
|
if (!session?.profile?.id) {
|
||||||
@@ -601,11 +605,15 @@ async function checkDormantMembers(sessionId) {
|
|||||||
}
|
}
|
||||||
const config = readConfig(profileId);
|
const config = readConfig(profileId);
|
||||||
const skipMap = new Map();
|
const skipMap = new Map();
|
||||||
config.forEach((entry) => {
|
const configEntryMap = new Map();
|
||||||
|
config.forEach((entry, index) => {
|
||||||
if (entry?.id) {
|
if (entry?.id) {
|
||||||
skipMap.set(String(entry.id), !!entry.skipDormantCheck);
|
const id = String(entry.id);
|
||||||
|
skipMap.set(id, !!entry.skipDormantCheck);
|
||||||
|
configEntryMap.set(id, { entry, index });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let configChanged = false;
|
||||||
|
|
||||||
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
|
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
|
||||||
if (stores.length === 0) {
|
if (stores.length === 0) {
|
||||||
@@ -624,7 +632,11 @@ async function checkDormantMembers(sessionId) {
|
|||||||
}
|
}
|
||||||
let members = [];
|
let members = [];
|
||||||
try {
|
try {
|
||||||
members = await foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader);
|
members = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader),
|
||||||
|
{ label: 'fetchStoreMembers' }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message);
|
console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message);
|
||||||
continue;
|
continue;
|
||||||
@@ -635,6 +647,14 @@ async function checkDormantMembers(sessionId) {
|
|||||||
}
|
}
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null;
|
const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null;
|
||||||
|
if (Number.isFinite(lastFetchMs)) {
|
||||||
|
const configEntry = configEntryMap.get(storeId)?.entry;
|
||||||
|
const lastPickupAt = new Date(lastFetchMs).toISOString();
|
||||||
|
if (configEntry && configEntry.lastPickupAt !== lastPickupAt) {
|
||||||
|
configEntry.lastPickupAt = lastPickupAt;
|
||||||
|
configChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!lastFetchMs || lastFetchMs < fourMonthsAgo) {
|
if (!lastFetchMs || lastFetchMs < fourMonthsAgo) {
|
||||||
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
|
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
|
||||||
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
|
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
|
||||||
@@ -660,6 +680,13 @@ async function checkDormantMembers(sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (configChanged) {
|
||||||
|
try {
|
||||||
|
writeConfig(profileId, config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[DORMANT] Letzte Abholung für Profil ${profileId} konnte nicht gespeichert werden:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDormantMembershipCheck(sessionId) {
|
function scheduleDormantMembershipCheck(sessionId) {
|
||||||
@@ -674,6 +701,24 @@ function scheduleDormantMembershipCheck(sessionId) {
|
|||||||
{ timezone: 'Europe/Berlin' }
|
{ timezone: 'Europe/Berlin' }
|
||||||
);
|
);
|
||||||
sessionStore.attachJob(sessionId, job);
|
sessionStore.attachJob(sessionId, job);
|
||||||
|
const session = sessionStore.get(sessionId);
|
||||||
|
const profileId = session?.profile?.id;
|
||||||
|
if (!profileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = readConfig(profileId);
|
||||||
|
const missingIds = getMissingLastPickupStoreIds(config);
|
||||||
|
if (missingIds.length === 0) {
|
||||||
|
if (session.dormantBootstrapSignature) {
|
||||||
|
sessionStore.update(sessionId, { dormantBootstrapSignature: null });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signature = missingIds.sort().join(',');
|
||||||
|
if (session.dormantBootstrapSignature === signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionStore.update(sessionId, { dormantBootstrapSignature: signature });
|
||||||
setTimeout(() => checkDormantMembers(sessionId), randomDelayMs(30, 180));
|
setTimeout(() => checkDormantMembers(sessionId), randomDelayMs(30, 180));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
services/sessionRefresh.js
Normal file
80
services/sessionRefresh.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const foodsharingClient = require('./foodsharingClient');
|
||||||
|
const sessionStore = require('./sessionStore');
|
||||||
|
|
||||||
|
function isUnauthorizedError(error) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
return status === 401 || status === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession(session, { label } = {}) {
|
||||||
|
if (!session?.credentials?.email || !session?.credentials?.password) {
|
||||||
|
console.warn(
|
||||||
|
`[SESSION] Session ${session?.id || 'unbekannt'} kann nicht erneuert werden – keine Zugangsdaten gespeichert.`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const refreshed = await foodsharingClient.login(
|
||||||
|
session.credentials.email,
|
||||||
|
session.credentials.password
|
||||||
|
);
|
||||||
|
sessionStore.update(session.id, {
|
||||||
|
cookieHeader: refreshed.cookieHeader,
|
||||||
|
csrfToken: refreshed.csrfToken,
|
||||||
|
profile: {
|
||||||
|
...session.profile,
|
||||||
|
...refreshed.profile
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[SESSION] Session ${session.id} wurde erfolgreich erneuert${label ? ` (${label})` : ''}.`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[SESSION] Session ${session?.id || 'unbekannt'} konnte nicht erneuert werden${label ? ` (${label})` : ''}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession(session) {
|
||||||
|
if (!session?.profile?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!session.cookieHeader) {
|
||||||
|
return refreshSession(session, { label: 'missing-cookie' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSessionRetry(session, action, { label } = {}) {
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Session fehlt');
|
||||||
|
}
|
||||||
|
if (!session.cookieHeader && session.credentials) {
|
||||||
|
const refreshed = await refreshSession(session, { label });
|
||||||
|
if (!refreshed) {
|
||||||
|
throw new Error('Session konnte nicht erneuert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnauthorizedError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const refreshed = await refreshSession(session, { label });
|
||||||
|
if (!refreshed) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureSession,
|
||||||
|
refreshSession,
|
||||||
|
withSessionRetry
|
||||||
|
};
|
||||||
@@ -347,6 +347,13 @@ const AdminSettingsPanel = ({
|
|||||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
placeholder="Bot-Token"
|
placeholder="Bot-Token"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adminSettings.notifications?.telegram?.chatId || ''}
|
||||||
|
onChange={(event) => onNotificationChange('telegram', 'chatId', event.target.value)}
|
||||||
|
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
placeholder="Admin-Chat-ID"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -47,6 +49,57 @@ const ColumnSelectFilter = ({ column, options, placeholder = 'Alle' }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatLastPickupLabel = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('de-DE');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastPickupRelative = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
return formatDistanceToNowStrict(date, { addSuffix: true, locale: de });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastPickupTimestamp = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
const time = date.getTime();
|
||||||
|
return Number.isNaN(time) ? -1 : time;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastPickupStyle = (value) => {
|
||||||
|
const time = getLastPickupTimestamp(value);
|
||||||
|
if (time <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const ageDays = (Date.now() - time) / (1000 * 60 * 60 * 24);
|
||||||
|
if (ageDays < 90) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const minDays = 90;
|
||||||
|
const maxDays = 180;
|
||||||
|
const ratio = Math.min(Math.max((ageDays - minDays) / (maxDays - minDays), 0), 1);
|
||||||
|
const start = { r: 220, g: 38, b: 38 };
|
||||||
|
const end = { r: 127, g: 29, b: 29 };
|
||||||
|
const r = Math.round(start.r + (end.r - start.r) * ratio);
|
||||||
|
const g = Math.round(start.g + (end.g - start.g) * ratio);
|
||||||
|
const b = Math.round(start.b + (end.b - start.b) * ratio);
|
||||||
|
return { color: `rgb(${r}, ${g}, ${b})` };
|
||||||
|
};
|
||||||
|
|
||||||
function readConfigTableState() {
|
function readConfigTableState() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { sorting: [], columnFilters: [] };
|
return { sorting: [], columnFilters: [] };
|
||||||
@@ -222,11 +275,53 @@ const DashboardView = ({
|
|||||||
sortingFn: 'alphanumeric',
|
sortingFn: 'alphanumeric',
|
||||||
filterFn: 'includesString'
|
filterFn: 'includesString'
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor('lastPickupAt', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between text-left font-semibold"
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<span>Letzte Abholung</span>
|
||||||
|
{column.getIsSorted() ? (
|
||||||
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const lastPickupAt = row.original.lastPickupAt;
|
||||||
|
const style = getLastPickupStyle(lastPickupAt);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-sm text-gray-700"
|
||||||
|
style={style}
|
||||||
|
title={formatLastPickupLabel(lastPickupAt)}
|
||||||
|
>
|
||||||
|
{formatLastPickupRelative(lastPickupAt)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: (rowA, rowB, columnId) => {
|
||||||
|
const a = getLastPickupTimestamp(rowA.getValue(columnId));
|
||||||
|
const b = getLastPickupTimestamp(rowB.getValue(columnId));
|
||||||
|
return a - b;
|
||||||
|
},
|
||||||
|
enableColumnFilter: false
|
||||||
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: 'checkProfileId',
|
id: 'checkProfileId',
|
||||||
header: () => <span>Profil prüfen</span>,
|
header: () => <span>Profil prüfen</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-center" title={row.original.lastFetchDate ? `Letzte Abholung: ${row.original.lastFetchDate}` : 'Keine Info zur letzten Abholung'}>
|
<div
|
||||||
|
className="text-center"
|
||||||
|
title={
|
||||||
|
row.original.lastPickupAt
|
||||||
|
? `Letzte Abholung: ${formatLastPickupLabel(row.original.lastPickupAt)}`
|
||||||
|
: 'Keine Info zur letzten Abholung'
|
||||||
|
}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@@ -242,7 +337,14 @@ const DashboardView = ({
|
|||||||
id: 'skipDormantCheck',
|
id: 'skipDormantCheck',
|
||||||
header: () => <span>Ruhe-Prüfung</span>,
|
header: () => <span>Ruhe-Prüfung</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-center" title={row.original.lastFetchDate ? `Letzte Abholung: ${row.original.lastFetchDate}` : 'Keine Info zur letzten Abholung'}>
|
<div
|
||||||
|
className="text-center"
|
||||||
|
title={
|
||||||
|
row.original.lastPickupAt
|
||||||
|
? `Letzte Abholung: ${formatLastPickupLabel(row.original.lastPickupAt)}`
|
||||||
|
: 'Keine Info zur letzten Abholung'
|
||||||
|
}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export const normalizeAdminSettings = (raw) => {
|
|||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
enabled: !!raw.notifications?.telegram?.enabled,
|
enabled: !!raw.notifications?.telegram?.enabled,
|
||||||
botToken: raw.notifications?.telegram?.botToken || ''
|
botToken: raw.notifications?.telegram?.botToken || '',
|
||||||
|
chatId: raw.notifications?.telegram?.chatId || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -72,7 +73,8 @@ export const serializeAdminSettings = (adminSettings) => {
|
|||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
enabled: !!adminSettings.notifications?.telegram?.enabled,
|
enabled: !!adminSettings.notifications?.telegram?.enabled,
|
||||||
botToken: adminSettings.notifications?.telegram?.botToken || ''
|
botToken: adminSettings.notifications?.telegram?.botToken || '',
|
||||||
|
chatId: adminSettings.notifications?.telegram?.chatId || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user