22ec13e559
* Adding flexible reminder options to birthdays * Fix database migration merge conflict * Truncate calendar popup descriptions * Log app version on backend startup * Add host-mounted data and backup folders * feat: add housekeeping module * fix: align housekeeping UI and add task creation * refactor: rebuild housekeeping experience * feat: support multiple housekeeping staff * feat: integrate housekeeping visits with calendar * feat: refine housekeeping visits and payments * feat: add housekeeping staff visit logs * feat: add housekeeping receipts and document folders * feat: localize housekeeping folders and chores * feat: refine housekeeping tabs and document folders * fix: sync housekeeping tab active state * feat: use configured app name in onboarding and manifest
260 lines
9.7 KiB
JavaScript
260 lines
9.7 KiB
JavaScript
/**
|
|
* Modul: Haushalt-Einstellungen (Preferences)
|
|
* Zweck: REST-API fuer haushaltweite Praeferenzen (via sync_config-Tabelle)
|
|
* Abhängigkeiten: express, server/db.js
|
|
*/
|
|
|
|
import { createLogger } from '../logger.js';
|
|
import express from 'express';
|
|
import * as db from '../db.js';
|
|
import { str, MAX_SHORT } from '../middleware/validate.js';
|
|
|
|
const log = createLogger('Preferences');
|
|
|
|
const router = express.Router();
|
|
|
|
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
|
const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
|
|
|
|
const VALID_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
|
const DEFAULT_CURRENCY = 'EUR';
|
|
const DEFAULT_APP_NAME = 'Oikos';
|
|
|
|
const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd', 'mdy_dot', 'dmy_dot', 'dmy_slash', 'ymd_dot', 'ymd_slash'];
|
|
const DEFAULT_DATE_FORMAT = 'mdy';
|
|
const VALID_TIME_FORMATS = ['24h', '12h'];
|
|
const DEFAULT_TIME_FORMAT = '24h';
|
|
|
|
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes'];
|
|
const VALID_WIDGET_SIZES = ['1x1', '1x2', '1x3', '1x4', '2x1', '2x2', '2x3', '2x4', '3x1', '3x2', '3x3', '3x4', '4x1', '4x2', '4x3', '4x4'];
|
|
|
|
// Modul-Slugs, die per Settings deaktiviert werden können.
|
|
// Dashboard und Settings sind absichtlich nicht enthalten — sie sind essentiell.
|
|
const TOGGLEABLE_MODULES = [
|
|
'tasks', 'calendar', 'meals', 'recipes', 'shopping',
|
|
'birthdays', 'notes', 'contacts', 'budget', 'documents',
|
|
];
|
|
|
|
function defaultWidgetSize(id) {
|
|
if (['tasks', 'calendar'].includes(id)) return '2x2';
|
|
if (['weather', 'shopping', 'notes'].includes(id)) return '2x1';
|
|
return '1x1';
|
|
}
|
|
|
|
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id, order) => ({
|
|
id,
|
|
visible: true,
|
|
order,
|
|
size: defaultWidgetSize(id),
|
|
})));
|
|
|
|
// --------------------------------------------------------
|
|
// Hilfsfunktionen
|
|
// --------------------------------------------------------
|
|
|
|
function cfgGet(key) {
|
|
const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
|
|
return row ? row.value : null;
|
|
}
|
|
|
|
function cfgSet(key, value) {
|
|
db.get().prepare(`
|
|
INSERT INTO sync_config (key, value)
|
|
VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
`).run(key, value);
|
|
}
|
|
|
|
function cfgDelete(key) {
|
|
db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Widget-Hilfsfunktionen
|
|
// --------------------------------------------------------
|
|
|
|
function parseWidgetConfig(raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw ?? DEFAULT_WIDGET_CONFIG);
|
|
return normalizeWidgetConfig(parsed);
|
|
} catch {
|
|
return JSON.parse(DEFAULT_WIDGET_CONFIG);
|
|
}
|
|
}
|
|
|
|
function parseDisabledModules(raw) {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((m) => typeof m === 'string' && TOGGLEABLE_MODULES.includes(m));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function normalizeWidgetConfig(input) {
|
|
const valid = Array.isArray(input)
|
|
? input
|
|
.filter((w) => w && typeof w === 'object' && VALID_WIDGET_IDS.includes(w.id))
|
|
.map((w, order) => ({
|
|
id: w.id,
|
|
visible: w.visible !== false,
|
|
order: Number.isFinite(Number(w.order)) ? Number(w.order) : order,
|
|
size: VALID_WIDGET_SIZES.includes(w.size) ? w.size : defaultWidgetSize(w.id),
|
|
}))
|
|
: [];
|
|
|
|
// Fehlende Widget-IDs am Ende ergänzen
|
|
const presentIds = new Set(valid.map((w) => w.id));
|
|
for (const id of VALID_WIDGET_IDS) {
|
|
if (!presentIds.has(id)) {
|
|
valid.push({ id, visible: true, order: valid.length, size: defaultWidgetSize(id) });
|
|
}
|
|
}
|
|
return valid
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((w, order) => ({ ...w, order }));
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// GET /api/v1/preferences
|
|
// Alle Haushalt-Praeferenzen lesen.
|
|
// Response: { data: { visible_meal_types: string[] } }
|
|
// --------------------------------------------------------
|
|
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
|
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
|
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
|
const dateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
|
|
const timeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT;
|
|
const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
|
|
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
|
const disabledModules = parseDisabledModules(cfgGet('disabled_modules'));
|
|
|
|
res.json({
|
|
data: {
|
|
visible_meal_types: visibleMealTypes,
|
|
currency,
|
|
date_format: dateFormat,
|
|
time_format: timeFormat,
|
|
app_name: appName,
|
|
dashboard_widgets: dashboardWidgets,
|
|
disabled_modules: disabledModules,
|
|
housekeeping_payment_tasks: cfgGet('housekeeping_payment_tasks') === '1',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
log.error('GET /', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// PUT /api/v1/preferences
|
|
// Haushalt-Praeferenzen aktualisieren.
|
|
// Body: { visible_meal_types: string[] }
|
|
// Response: { data: { visible_meal_types: string[] } }
|
|
// --------------------------------------------------------
|
|
|
|
router.put('/', (req, res) => {
|
|
try {
|
|
const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules, housekeeping_payment_tasks } = req.body;
|
|
|
|
if (visible_meal_types !== undefined) {
|
|
if (!Array.isArray(visible_meal_types)) {
|
|
return res.status(400).json({ error: 'visible_meal_types muss ein Array sein', code: 400 });
|
|
}
|
|
const filtered = visible_meal_types.filter((t) => VALID_MEAL_TYPES.includes(t));
|
|
if (filtered.length === 0) {
|
|
return res.status(400).json({ error: 'Mindestens ein Mahlzeit-Typ muss aktiv sein', code: 400 });
|
|
}
|
|
cfgSet('visible_meal_types', filtered.join(','));
|
|
}
|
|
|
|
if (currency !== undefined) {
|
|
if (!VALID_CURRENCIES.includes(currency)) {
|
|
return res.status(400).json({ error: `Ungültige Währung. Erlaubt: ${VALID_CURRENCIES.join(', ')}`, code: 400 });
|
|
}
|
|
cfgSet('currency', currency);
|
|
}
|
|
|
|
if (date_format !== undefined) {
|
|
if (!VALID_DATE_FORMATS.includes(date_format)) {
|
|
return res.status(400).json({ error: `Ungültiges Datumsformat. Erlaubt: ${VALID_DATE_FORMATS.join(', ')}`, code: 400 });
|
|
}
|
|
cfgSet('date_format', date_format);
|
|
}
|
|
|
|
if (time_format !== undefined) {
|
|
if (!VALID_TIME_FORMATS.includes(time_format)) {
|
|
return res.status(400).json({ error: `Invalid time format. Allowed: ${VALID_TIME_FORMATS.join(', ')}`, code: 400 });
|
|
}
|
|
cfgSet('time_format', time_format);
|
|
}
|
|
|
|
if (app_name !== undefined) {
|
|
const vAppName = str(app_name, 'Application name', { max: MAX_SHORT, required: false });
|
|
if (vAppName.error) return res.status(400).json({ error: vAppName.error, code: 400 });
|
|
if (vAppName.value) cfgSet('app_name', vAppName.value);
|
|
else cfgDelete('app_name');
|
|
}
|
|
|
|
if (dashboard_widgets !== undefined) {
|
|
if (!Array.isArray(dashboard_widgets)) {
|
|
return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 });
|
|
}
|
|
const normalized = normalizeWidgetConfig(dashboard_widgets);
|
|
cfgSet('dashboard_widgets', JSON.stringify(normalized));
|
|
}
|
|
|
|
if (disabled_modules !== undefined) {
|
|
if (!Array.isArray(disabled_modules)) {
|
|
return res.status(400).json({ error: 'disabled_modules muss ein Array sein', code: 400 });
|
|
}
|
|
const filtered = disabled_modules
|
|
.filter((m) => typeof m === 'string' && TOGGLEABLE_MODULES.includes(m));
|
|
const unique = [...new Set(filtered)];
|
|
cfgSet('disabled_modules', JSON.stringify(unique));
|
|
}
|
|
|
|
if (housekeeping_payment_tasks !== undefined) {
|
|
if (typeof housekeeping_payment_tasks !== 'boolean') {
|
|
return res.status(400).json({ error: 'housekeeping_payment_tasks must be a boolean', code: 400 });
|
|
}
|
|
cfgSet('housekeeping_payment_tasks', housekeeping_payment_tasks ? '1' : '0');
|
|
}
|
|
|
|
const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
|
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
|
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
|
const savedDateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
|
|
const savedTimeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT;
|
|
const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
|
|
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
|
const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules'));
|
|
const savedHousekeepingPaymentTasks = cfgGet('housekeeping_payment_tasks') === '1';
|
|
|
|
res.json({
|
|
data: {
|
|
visible_meal_types: savedMealTypes,
|
|
currency: savedCurrency,
|
|
date_format: savedDateFormat,
|
|
time_format: savedTimeFormat,
|
|
app_name: savedAppName,
|
|
dashboard_widgets: savedWidgets,
|
|
disabled_modules: savedDisabledModules,
|
|
housekeeping_payment_tasks: savedHousekeepingPaymentTasks,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
log.error('PUT /', err);
|
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
|
}
|
|
});
|
|
|
|
export default router;
|