Journal für abholungen
This commit is contained in:
246
server.js
246
server.js
@@ -19,6 +19,13 @@ const notificationService = require('./services/notificationService');
|
||||
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
||||
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
||||
const requestLogStore = require('./services/requestLogStore');
|
||||
const {
|
||||
readJournal,
|
||||
writeJournal,
|
||||
saveJournalImage,
|
||||
deleteJournalImage,
|
||||
getProfileImageDir
|
||||
} = require('./services/journalStore');
|
||||
const { withSessionRetry } = require('./services/sessionRefresh');
|
||||
const {
|
||||
getStoreStatus: getCachedStoreStatusEntry,
|
||||
@@ -69,7 +76,7 @@ function haversineDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
}
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'build')));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
@@ -229,6 +236,45 @@ function getCachedStoreStatus(storeId) {
|
||||
return getCachedStoreStatusEntry(storeId);
|
||||
}
|
||||
|
||||
function normalizeJournalReminder(reminder = {}) {
|
||||
return {
|
||||
enabled: !!reminder.enabled,
|
||||
interval: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
|
||||
? reminder.interval
|
||||
: 'yearly',
|
||||
daysBefore: Number.isFinite(reminder.daysBefore) ? Math.max(0, reminder.daysBefore) : 42
|
||||
};
|
||||
}
|
||||
|
||||
function parseDataUrl(dataUrl) {
|
||||
if (typeof dataUrl !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const mimeType = match[1];
|
||||
const buffer = Buffer.from(match[2], 'base64');
|
||||
return { mimeType, buffer };
|
||||
}
|
||||
|
||||
function resolveImageExtension(mimeType) {
|
||||
if (mimeType === 'image/jpeg') {
|
||||
return '.jpg';
|
||||
}
|
||||
if (mimeType === 'image/png') {
|
||||
return '.png';
|
||||
}
|
||||
if (mimeType === 'image/gif') {
|
||||
return '.gif';
|
||||
}
|
||||
if (mimeType === 'image/webp') {
|
||||
return '.webp';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function ingestStoreLocations(stores = []) {
|
||||
let changed = false;
|
||||
stores.forEach((store) => {
|
||||
@@ -990,6 +1036,204 @@ app.post('/api/user/preferences/location', requireAuth, (req, res) => {
|
||||
res.json({ location: updated.location });
|
||||
});
|
||||
|
||||
app.get('/api/journal', requireAuth, (req, res) => {
|
||||
const entries = readJournal(req.session.profile.id);
|
||||
const normalized = entries.map((entry) => ({
|
||||
...entry,
|
||||
images: Array.isArray(entry.images)
|
||||
? entry.images.map((image) => ({
|
||||
...image,
|
||||
url: `/api/journal/images/${image.id}`
|
||||
}))
|
||||
: []
|
||||
}));
|
||||
res.json(normalized);
|
||||
});
|
||||
|
||||
app.post('/api/journal', requireAuth, (req, res) => {
|
||||
const profileId = req.session.profile.id;
|
||||
const { storeId, storeName, pickupDate, note, reminder, images } = req.body || {};
|
||||
if (!storeId || !pickupDate) {
|
||||
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
|
||||
}
|
||||
const parsedDate = new Date(`${pickupDate}T00:00:00`);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
|
||||
}
|
||||
|
||||
const entryId = uuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
const normalizedReminder = normalizeJournalReminder(reminder);
|
||||
const maxImages = 5;
|
||||
const maxImageBytes = 5 * 1024 * 1024;
|
||||
const collectedImages = [];
|
||||
|
||||
if (Array.isArray(images)) {
|
||||
images.slice(0, maxImages).forEach((image) => {
|
||||
const payload = parseDataUrl(image?.dataUrl);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (payload.buffer.length > maxImageBytes) {
|
||||
return;
|
||||
}
|
||||
const imageId = uuid();
|
||||
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
|
||||
const filename = `${imageId}${ext || ''}`;
|
||||
const saved = saveJournalImage(profileId, {
|
||||
id: imageId,
|
||||
filename,
|
||||
buffer: payload.buffer
|
||||
});
|
||||
collectedImages.push({
|
||||
id: imageId,
|
||||
filename: saved.filename,
|
||||
originalName: image?.name || saved.filename,
|
||||
mimeType: payload.mimeType,
|
||||
uploadedAt: timestamp
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: entryId,
|
||||
storeId: String(storeId),
|
||||
storeName: storeName || `Store ${storeId}`,
|
||||
pickupDate,
|
||||
note: note || '',
|
||||
reminder: normalizedReminder,
|
||||
images: collectedImages,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastReminderAt: null
|
||||
};
|
||||
|
||||
const entries = readJournal(profileId);
|
||||
entries.push(entry);
|
||||
writeJournal(profileId, entries);
|
||||
|
||||
res.json({
|
||||
...entry,
|
||||
images: collectedImages.map((image) => ({
|
||||
...image,
|
||||
url: `/api/journal/images/${image.id}`
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/journal/:id', requireAuth, (req, res) => {
|
||||
const profileId = req.session.profile.id;
|
||||
const { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {};
|
||||
if (!storeId || !pickupDate) {
|
||||
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
|
||||
}
|
||||
const parsedDate = new Date(`${pickupDate}T00:00:00`);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
|
||||
}
|
||||
|
||||
const entries = readJournal(profileId);
|
||||
const index = entries.findIndex((entry) => entry.id === req.params.id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
const existing = entries[index];
|
||||
const normalizedReminder = normalizeJournalReminder(reminder);
|
||||
const maxImages = 5;
|
||||
const maxImageBytes = 5 * 1024 * 1024;
|
||||
const keepIds = Array.isArray(keepImageIds) ? new Set(keepImageIds.map(String)) : new Set();
|
||||
const keptImages = (existing.images || []).filter((image) => keepIds.has(String(image.id)));
|
||||
const removedImages = (existing.images || []).filter((image) => !keepIds.has(String(image.id)));
|
||||
removedImages.forEach((image) => deleteJournalImage(profileId, image.filename));
|
||||
|
||||
const availableSlots = Math.max(0, maxImages - keptImages.length);
|
||||
const collectedImages = [...keptImages];
|
||||
|
||||
if (Array.isArray(images) && availableSlots > 0) {
|
||||
images.slice(0, availableSlots).forEach((image) => {
|
||||
const payload = parseDataUrl(image?.dataUrl);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (payload.buffer.length > maxImageBytes) {
|
||||
return;
|
||||
}
|
||||
const imageId = uuid();
|
||||
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
|
||||
const filename = `${imageId}${ext || ''}`;
|
||||
const saved = saveJournalImage(profileId, {
|
||||
id: imageId,
|
||||
filename,
|
||||
buffer: payload.buffer
|
||||
});
|
||||
collectedImages.push({
|
||||
id: imageId,
|
||||
filename: saved.filename,
|
||||
originalName: image?.name || saved.filename,
|
||||
mimeType: payload.mimeType,
|
||||
uploadedAt: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const pickupDateChanged = existing.pickupDate !== pickupDate;
|
||||
const updated = {
|
||||
...existing,
|
||||
storeId: String(storeId),
|
||||
storeName: storeName || existing.storeName || `Store ${storeId}`,
|
||||
pickupDate,
|
||||
note: note || '',
|
||||
reminder: normalizedReminder,
|
||||
images: collectedImages,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastReminderAt: pickupDateChanged ? null : existing.lastReminderAt
|
||||
};
|
||||
|
||||
entries[index] = updated;
|
||||
writeJournal(profileId, entries);
|
||||
|
||||
res.json({
|
||||
...updated,
|
||||
images: collectedImages.map((image) => ({
|
||||
...image,
|
||||
url: `/api/journal/images/${image.id}`
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/journal/:id', requireAuth, (req, res) => {
|
||||
const profileId = req.session.profile.id;
|
||||
const entries = readJournal(profileId);
|
||||
const filtered = entries.filter((entry) => entry.id !== req.params.id);
|
||||
if (filtered.length === entries.length) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
const removed = entries.find((entry) => entry.id === req.params.id);
|
||||
if (removed?.images) {
|
||||
removed.images.forEach((image) => deleteJournalImage(profileId, image.filename));
|
||||
}
|
||||
writeJournal(profileId, filtered);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/journal/images/:imageId', requireAuth, (req, res) => {
|
||||
const profileId = req.session.profile.id;
|
||||
const entries = readJournal(profileId);
|
||||
const imageEntry = entries
|
||||
.flatMap((entry) => (Array.isArray(entry.images) ? entry.images : []))
|
||||
.find((image) => image.id === req.params.imageId);
|
||||
if (!imageEntry?.filename) {
|
||||
return res.status(404).json({ error: 'Bild nicht gefunden' });
|
||||
}
|
||||
const baseDir = getProfileImageDir(profileId);
|
||||
const filePath = path.join(baseDir, imageEntry.filename);
|
||||
if (!filePath || !path.resolve(filePath).startsWith(path.resolve(baseDir))) {
|
||||
return res.status(404).json({ error: 'Bild nicht gefunden' });
|
||||
}
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
app.get('/api/config', requireAuth, (req, res) => {
|
||||
const config = readConfig(req.session.profile.id);
|
||||
res.json(config);
|
||||
|
||||
Reference in New Issue
Block a user