Adding /api/v1/budget/categories and /api/v1/budget/categories endpoints
This commit is contained in:
@@ -69,6 +69,20 @@ function idParam(name = 'id', description = 'Resource ID') {
|
||||
};
|
||||
}
|
||||
|
||||
function langParam() {
|
||||
return {
|
||||
name: 'lang',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Language code for localized labels. Supported values: ar, de, el, en, es, fr, hi, it, ja, pt, ru, sv, tr, uk, zh. Defaults to en.',
|
||||
schema: {
|
||||
type: 'string',
|
||||
default: 'en',
|
||||
enum: ['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPaths() {
|
||||
return {
|
||||
'/health': {
|
||||
@@ -360,9 +374,11 @@ function buildPaths() {
|
||||
'/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) },
|
||||
'/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) },
|
||||
'/api/v1/budget/categories': {
|
||||
get: op({ summary: 'List budget categories', tag: 'Budget', params: [langParam()] }),
|
||||
post: op({ summary: 'Create budget category', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/budget/categories/{categoryKey}/subcategories': {
|
||||
get: op({ summary: 'List subcategories for a budget category', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }, langParam()] }),
|
||||
post: op({ summary: 'Create budget subcategory', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }], stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
'/api/v1/budget': {
|
||||
|
||||
@@ -6,12 +6,101 @@
|
||||
|
||||
import { createLogger } from '../logger.js';
|
||||
import express from 'express';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'path';
|
||||
import * as db from '../db.js';
|
||||
import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js';
|
||||
|
||||
const log = createLogger('Budget');
|
||||
|
||||
const router = express.Router();
|
||||
const LOCALE_CACHE = new Map();
|
||||
const SUPPORTED_LANGS = new Set(['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh']);
|
||||
const CATEGORY_LABEL_KEYS = {
|
||||
housing: 'catHousing',
|
||||
food: 'catFood',
|
||||
transport: 'catTransport',
|
||||
personal_health: 'catPersonalHealth',
|
||||
leisure: 'catLeisure',
|
||||
shopping_clothing: 'catShoppingClothing',
|
||||
education: 'catEducation',
|
||||
financial_other: 'catFinancialOther',
|
||||
'Erwerbseinkommen': 'catEarnedIncome',
|
||||
'Kapitalerträge': 'catInvestmentIncome',
|
||||
'Geschenke & Transfers': 'catTransferGiftIncome',
|
||||
'Sozialleistungen': 'catGovernmentBenefits',
|
||||
'Sonstiges Einkommen': 'catOtherIncome',
|
||||
};
|
||||
const SUBCATEGORY_LABEL_KEYS = {
|
||||
rent_mortgage: 'subcatRentMortgage',
|
||||
condominium: 'subcatCondominium',
|
||||
utilities: 'subcatUtilities',
|
||||
internet_tv_phone: 'subcatInternetTvPhone',
|
||||
renovation_maintenance: 'subcatRenovationMaintenance',
|
||||
cleaning: 'subcatCleaning',
|
||||
groceries: 'subcatGroceries',
|
||||
restaurants_bars: 'subcatRestaurantsBars',
|
||||
snacks_fast_food: 'subcatSnacksFastFood',
|
||||
bakery: 'subcatBakery',
|
||||
fuel: 'subcatFuel',
|
||||
parking_tolls: 'subcatParkingTolls',
|
||||
public_transport: 'subcatPublicTransport',
|
||||
apps_taxi: 'subcatAppsTaxi',
|
||||
maintenance_insurance: 'subcatMaintenanceInsurance',
|
||||
pharmacy: 'subcatPharmacy',
|
||||
health_insurance: 'subcatHealthInsurance',
|
||||
gym_sports: 'subcatGymSports',
|
||||
beauty_cosmetics: 'subcatBeautyCosmetics',
|
||||
travel: 'subcatTravel',
|
||||
streaming: 'subcatStreaming',
|
||||
events: 'subcatEvents',
|
||||
hobbies: 'subcatHobbies',
|
||||
clothes_shoes: 'subcatClothesShoes',
|
||||
electronics: 'subcatElectronics',
|
||||
gifts: 'subcatGifts',
|
||||
courses_college: 'subcatCoursesCollege',
|
||||
school_supplies: 'subcatSchoolSupplies',
|
||||
languages: 'subcatLanguages',
|
||||
loans_interest: 'subcatLoansInterest',
|
||||
bank_fees: 'subcatBankFees',
|
||||
insurance_other: 'subcatInsuranceOther',
|
||||
investments: 'subcatInvestments',
|
||||
taxes: 'subcatTaxes',
|
||||
};
|
||||
|
||||
function normalizeLang(raw) {
|
||||
const lang = String(raw || 'en').trim().toLowerCase();
|
||||
const base = lang.split(/[-_]/)[0];
|
||||
return SUPPORTED_LANGS.has(base) ? base : 'en';
|
||||
}
|
||||
|
||||
function budgetMessages(lang) {
|
||||
const normalized = normalizeLang(lang);
|
||||
if (!LOCALE_CACHE.has(normalized)) {
|
||||
const localePath = path.join(import.meta.dirname, '..', '..', 'public', 'locales', `${normalized}.json`);
|
||||
const parsed = JSON.parse(readFileSync(localePath, 'utf-8'));
|
||||
LOCALE_CACHE.set(normalized, parsed.budget || {});
|
||||
}
|
||||
return LOCALE_CACHE.get(normalized);
|
||||
}
|
||||
|
||||
function localizedCategory(category, lang) {
|
||||
const budget = budgetMessages(lang);
|
||||
const labelKey = CATEGORY_LABEL_KEYS[category.key];
|
||||
return {
|
||||
...category,
|
||||
label: labelKey ? (budget[labelKey] || category.name) : category.name,
|
||||
};
|
||||
}
|
||||
|
||||
function localizedSubcategory(subcategory, lang) {
|
||||
const budget = budgetMessages(lang);
|
||||
const labelKey = SUBCATEGORY_LABEL_KEYS[subcategory.key];
|
||||
return {
|
||||
...subcategory,
|
||||
label: labelKey ? (budget[labelKey] || subcategory.name) : subcategory.name,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Wiederkehrende Einträge: fehlende Instanzen für einen Monat erzeugen
|
||||
@@ -255,6 +344,53 @@ router.get('/meta', (req, res) => {
|
||||
res.json({ data: loadBudgetMeta() });
|
||||
});
|
||||
|
||||
router.get('/categories', (req, res) => {
|
||||
try {
|
||||
const lang = normalizeLang(req.query.lang);
|
||||
const categories = db.get().prepare(`
|
||||
SELECT key, name, type, sort_order
|
||||
FROM budget_categories
|
||||
ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
data: categories.map((category) => localizedCategory(category, lang)),
|
||||
lang,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('GET /categories error:', err);
|
||||
res.status(500).json({ error: 'Internal error', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/categories/:categoryKey/subcategories', (req, res) => {
|
||||
try {
|
||||
const lang = normalizeLang(req.query.lang);
|
||||
const category = db.get().prepare(`
|
||||
SELECT key, name, type, sort_order
|
||||
FROM budget_categories
|
||||
WHERE key = ?
|
||||
`).get(req.params.categoryKey);
|
||||
if (!category) return res.status(404).json({ error: 'Category not found.', code: 404 });
|
||||
|
||||
const subcategories = db.get().prepare(`
|
||||
SELECT key, category_key, name, sort_order
|
||||
FROM budget_subcategories
|
||||
WHERE category_key = ?
|
||||
ORDER BY sort_order ASC, name COLLATE NOCASE ASC
|
||||
`).all(category.key);
|
||||
|
||||
res.json({
|
||||
data: subcategories.map((subcategory) => localizedSubcategory(subcategory, lang)),
|
||||
category: localizedCategory(category, lang),
|
||||
lang,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('GET /categories/:categoryKey/subcategories error:', err);
|
||||
res.status(500).json({ error: 'Internal error', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/categories', (req, res) => {
|
||||
try {
|
||||
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
|
||||
|
||||
Reference in New Issue
Block a user