From adc4cfbee81ff8e3abed03f22203168487248d87 Mon Sep 17 00:00:00 2001
From: MDeeApp <6595194+MDeeApp@users.noreply.github.com>
Date: Sat, 11 Oct 2025 19:34:54 +0200
Subject: [PATCH] localization for ntfy and mail
---
Dockerfile | 1 +
README.md | 8 ++
docker-compose.yml | 5 +-
frontend/Dockerfile | 12 +-
frontend/src/api/config.ts | 6 +
frontend/src/locales/de/common.json | 10 ++
frontend/src/locales/en/common.json | 10 ++
frontend/src/routes/ContractDetail.tsx | 8 +-
frontend/src/routes/ContractForm.tsx | 66 ++++++++--
frontend/src/routes/ContractsList.tsx | 13 +-
frontend/src/routes/Settings.tsx | 89 +++++++++++--
frontend/src/types/shared.d.ts | 10 ++
frontend/src/utils/categories.ts | 45 +++++++
frontend/tsconfig.json | 8 +-
frontend/vite.config.ts | 9 ++
shared/defaultCategories.json | 37 ++++++
src/categoriesStore.ts | 53 +++++++-
src/categoryDefaults.ts | 43 +++++++
src/config.ts | 6 +
src/db.ts | 13 +-
src/index.ts | 25 +++-
src/notifications.ts | 171 +++++++++++++++++++++++--
src/runtimeSettings.ts | 23 +++-
src/scheduler.ts | 10 +-
src/settingsStore.ts | 2 +
25 files changed, 609 insertions(+), 74 deletions(-)
create mode 100644 frontend/src/types/shared.d.ts
create mode 100644 frontend/src/utils/categories.ts
create mode 100644 shared/defaultCategories.json
create mode 100644 src/categoryDefaults.ts
diff --git a/Dockerfile b/Dockerfile
index feb88b3..c2c62f9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,6 +10,7 @@ COPY package*.json tsconfig.json ./
RUN npm install
COPY src ./src
+COPY shared ./shared
RUN npm run build
diff --git a/README.md b/README.md
index 51f70b8..63b5519 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,8 @@ docker compose up --build
| `AUTH_TOKEN_EXPIRES_IN_HOURS` | `12` | Lebensdauer der Tokens. |
| `PAPERLESS_BASE_URL` | *(leer)* | API-URL deiner paperless-ngx Instanz. |
| `PAPERLESS_EXTERNAL_URL` | *(leer)* | Optionale externe URL für Direktlinks im Browser. |
+| `APP_EXTERNAL_URL` | *(leer)* | Optionale externe URL der App (z. B. für Links im iCal-Feed), überschreibt den Host der eingehenden Anfrage. |
+| `APP_LOCALE` | `de` | Sprache für systemseitige Nachrichten (z. B. ntfy, E-Mail-Betreff); derzeit `de` oder `en`. |
| `PAPERLESS_TOKEN` | *(leer)* | API-Token aus paperless-ngx. |
| `NTFY_SERVER_URL` / `NTFY_TOPIC` | *(leer)* | Aktiviert ntfy-Push-Benachrichtigungen. |
| `NTFY_TOKEN` / `NTFY_PRIORITY` | *(leer)* | Optionaler Bearer-Token & Priorität. |
@@ -100,6 +102,12 @@ docker compose up --build
Paperless ist **nicht verpflichtend**. Lässt du `PAPERLESS_*` leer, bleibt die Vertragsverwaltung voll funktionsfähig (Deadlines, Benachrichtigungen, iCal, ntfy, etc.). Die UI blendet paperless-spezifische Funktionen aus bzw. deaktiviert Buttons und zeigt Hinweise an. Sobald du später eine URL/Token hinterlegst, werden Suche und Direktlinks automatisch freigeschaltet.
+### iCal-Feed hinter eigenem Host bereitstellen
+
+Möchtest du den iCal-Feed unter einer separaten Domain ausliefern (z. B. `calendar.example.com`), kannst du den Request über einen Reverse Proxy auf `/api/calendar/feed.ics` weiterleiten. Damit Links innerhalb des Feeds korrekt auf deine eigentliche App zeigen, setze `APP_EXTERNAL_URL` (oder hinterlege den Wert in den Einstellungen unter „Externe URL der App“). Ist das Feld leer, verwendet der Dienst den Host der eingehenden Anfrage.
+
+Die Sprache der automatisch verschickten Benachrichtigungen (Mail, ntfy) kannst du über `APP_LOCALE` bzw. das Feld „Sprache für Benachrichtigungen“ in den Einstellungen auf Deutsch oder Englisch festlegen.
+
---
## API-Übersicht
diff --git a/docker-compose.yml b/docker-compose.yml
index 22173bd..a69255b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -31,6 +31,8 @@ services:
# - MAIL_USE_TLS=true
# - MAIL_FROM=contract-monitor@example.com
# - MAIL_TO=you@example.com
+ # - APP_EXTERNAL_URL=https://contracts.example.com
+ # - APP_LOCALE=en
# Optional: festen iCal-Token vorgeben
# - ICAL_SECRET=replace-with-secret
volumes:
@@ -39,7 +41,8 @@ services:
- "8080:8000"
contract-companion-ui:
build:
- context: ./frontend
+ context: .
+ dockerfile: frontend/Dockerfile
container_name: contract-companion-ui
restart: unless-stopped
ports:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index e0c80ed..270187a 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -2,17 +2,23 @@ FROM node:20-alpine AS build
WORKDIR /app
-COPY package*.json ./
+# Install dependencies
+COPY frontend/package*.json ./
RUN npm install
-COPY . .
+# Copy source files
+COPY frontend/tsconfig*.json ./
+COPY frontend/vite.config.ts ./
+COPY frontend/index.html ./
+COPY frontend/src ./src
+COPY shared ./shared
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
-COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts
index 27dbcf6..02e8ec8 100644
--- a/frontend/src/api/config.ts
+++ b/frontend/src/api/config.ts
@@ -6,6 +6,8 @@ export interface ServerConfig {
databasePath: string;
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
+ appExternalUrl: string | null;
+ appLocale: string;
paperlessConfigured: boolean;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
@@ -26,6 +28,8 @@ export interface SettingsResponse {
values: {
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
+ appExternalUrl: string | null;
+ appLocale: string;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
mailServer: string | null;
@@ -51,6 +55,8 @@ export interface SettingsResponse {
export type UpdateSettingsPayload = Partial<{
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
+ appExternalUrl: string | null;
+ appLocale: string;
paperlessToken: string | null;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json
index 46f2981..0712718 100644
--- a/frontend/src/locales/de/common.json
+++ b/frontend/src/locales/de/common.json
@@ -79,6 +79,7 @@
"edit": "Bearbeiten",
"deleted": "Vertrag gelöscht",
"deleteError": "Löschen fehlgeschlagen",
+ "categoryAddTitle": "Kategorie hinzufügen",
"categoryAdd": "Neue Kategorie hinzufügen",
"categoryAddDescription": "Erstelle eine neue Kategorie, die du sofort auswählen kannst.",
"categoryNameLabel": "Name der Kategorie",
@@ -86,6 +87,7 @@
"categoryNameRequired": "Bitte einen Kategorienamen angeben.",
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
+ "categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
"categoryNone": "Keine Kategorie",
"categoryLoading": "Kategorien werden geladen…",
"categoryLoadError": "Kategorien konnten nicht geladen werden."
@@ -177,7 +179,14 @@
"icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
+ "appExternalUrl": "Externe URL der App",
"paperlessExample": "https://paperless.example.com",
+ "appExternalExample": "https://contracts.example.com",
+ "appExternalUrlHelp": "Diese URL wird in iCal-Feeds und Links verwendet. Leer lassen, um Host des Aufrufs zu nutzen.",
+ "appLocaleLabel": "Sprache für Benachrichtigungen",
+ "appLocaleHelp": "Bestimmt die Sprache für ntfy-Pushs und Mail-Betreffs.",
+ "appLocaleGerman": "Deutsch",
+ "appLocaleEnglish": "Englisch",
"paperlessToken": "Paperless Token",
"paperlessTokenNew": "Neuen Token hinterlegen",
"paperlessTokenPlaceholder": "Token eingeben",
@@ -236,6 +245,7 @@
"categoryNameRequired": "Bitte einen Namen für die Kategorie eingeben.",
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
+ "categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
"categoryDeleted": "Kategorie gelöscht",
"categoryDeleteError": "Kategorie konnte nicht gelöscht werden",
"categoryEmpty": "Noch keine Kategorien vorhanden.",
diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json
index cfff61f..4729f09 100644
--- a/frontend/src/locales/en/common.json
+++ b/frontend/src/locales/en/common.json
@@ -79,6 +79,7 @@
"edit": "Edit",
"deleted": "Contract deleted",
"deleteError": "Failed to delete",
+ "categoryAddTitle": "Add category",
"categoryAdd": "Add new category",
"categoryAddDescription": "Create a new category you can select immediately.",
"categoryNameLabel": "Category name",
@@ -86,6 +87,7 @@
"categoryNameRequired": "Please provide a category name.",
"categoryCreated": "Category \"{{name}}\" saved",
"categoryCreateError": "Failed to create category",
+ "categoryExists": "Category \"{{name}}\" already exists",
"categoryNone": "No category",
"categoryLoading": "Loading categories…",
"categoryLoadError": "Could not load categories."
@@ -177,7 +179,14 @@
"icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless external URL (for direct link)",
+ "appExternalUrl": "App external URL",
"paperlessExample": "https://paperless.example.com",
+ "appExternalExample": "https://contracts.example.com",
+ "appExternalUrlHelp": "Used for links and iCal feeds. Leave empty to use the request host.",
+ "appLocaleLabel": "Notification language",
+ "appLocaleHelp": "Controls the language used in ntfy pushes and email subjects.",
+ "appLocaleGerman": "German",
+ "appLocaleEnglish": "English",
"paperlessToken": "Paperless token",
"paperlessTokenNew": "Provide new token",
"paperlessTokenPlaceholder": "Enter token",
@@ -236,6 +245,7 @@
"categoryNameRequired": "Please enter a category name.",
"categoryCreated": "Category \"{{name}}\" saved",
"categoryCreateError": "Failed to create category",
+ "categoryExists": "Category \"{{name}}\" already exists",
"categoryDeleted": "Category deleted",
"categoryDeleteError": "Failed to delete category",
"categoryEmpty": "No categories defined yet.",
diff --git a/frontend/src/routes/ContractDetail.tsx b/frontend/src/routes/ContractDetail.tsx
index e10af1e..96e2011 100644
--- a/frontend/src/routes/ContractDetail.tsx
+++ b/frontend/src/routes/ContractDetail.tsx
@@ -17,12 +17,13 @@ import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
import { fetchServerConfig, ServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { formatCurrency, formatDate } from "../utils/date";
+import { translateCategoryName } from "../utils/categories";
export default function ContractDetail() {
const { contractId } = useParams<{ contractId: string }>();
const navigate = useNavigate();
const id = Number(contractId);
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const { data: contract, isLoading } = useQuery({
queryKey: ["contracts", id],
@@ -56,6 +57,9 @@ export default function ContractDetail() {
? t("contractForm.fields.autoRenew")
: "–";
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
+ const categoryValue = contract?.category
+ ? translateCategoryName(contract.category, i18n.language) || contract.category
+ : "–";
if (!Number.isFinite(id)) {
return {t("contractForm.loadError")};
@@ -89,7 +93,7 @@ export default function ContractDetail() {
-
+
diff --git a/frontend/src/routes/ContractForm.tsx b/frontend/src/routes/ContractForm.tsx
index 27f864c..b8b2a56 100644
--- a/frontend/src/routes/ContractForm.tsx
+++ b/frontend/src/routes/ContractForm.tsx
@@ -33,6 +33,7 @@ import {
} from "../api/contracts";
import { fetchServerConfig } from "../api/config";
import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories";
+import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
@@ -94,7 +95,7 @@ export default function ContractForm({ mode }: Props) {
const id = contractId ? Number(contractId) : null;
const queryClient = useQueryClient();
const { showMessage } = useSnackbar();
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const {
control,
@@ -160,7 +161,8 @@ export default function ContractForm({ mode }: Props) {
await refetchCategories();
queryClient.invalidateQueries({ queryKey: ["categories"] });
setValue("category", category.name, { shouldDirty: true });
- showMessage(t("contracts.categoryCreated", { name: category.name }), "success");
+ const displayName = translateCategoryName(category.name, i18n.language) || category.name;
+ showMessage(t("contracts.categoryCreated", { name: displayName }), "success");
setCategoryDialogOpen(false);
setNewCategoryName("");
},
@@ -171,6 +173,34 @@ export default function ContractForm({ mode }: Props) {
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [newCategoryName, setNewCategoryName] = useState("");
+ const trimmedDialogCategoryName = newCategoryName.trim();
+ const dialogCategoryExists = useMemo(() => {
+ if (!trimmedDialogCategoryName) {
+ return false;
+ }
+ if (!categories) {
+ return false;
+ }
+ const normalizedInput = trimmedDialogCategoryName.toLowerCase();
+ const canonicalInput = getCanonicalDefaultCategoryName(trimmedDialogCategoryName)?.toLowerCase() ?? null;
+ return categories.some((category) => {
+ const normalizedCategory = category.name.toLowerCase();
+ return normalizedCategory === normalizedInput || (canonicalInput ? normalizedCategory === canonicalInput : false);
+ });
+ }, [categories, trimmedDialogCategoryName]);
+
+ const handleDialogCreateCategory = () => {
+ if (!trimmedDialogCategoryName) {
+ showMessage(t("contracts.categoryNameRequired"), "warning");
+ return;
+ }
+ if (dialogCategoryExists) {
+ const existingDisplayName = translateCategoryName(trimmedDialogCategoryName, i18n.language) || trimmedDialogCategoryName;
+ showMessage(t("contracts.categoryExists", { name: existingDisplayName }), "info");
+ return;
+ }
+ createCategoryMutation.mutate(trimmedDialogCategoryName);
+ };
const providerSuggestion = useMemo(
() => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null),
@@ -342,7 +372,15 @@ export default function ContractForm({ mode }: Props) {
render={({ field }) => {
const options = categories ?? [];
const value = field.value ?? "";
- const hasCurrentValue = Boolean(value) && options.some((category) => category.name.toLowerCase() === String(value).toLowerCase());
+ const normalizedValue = String(value).toLowerCase();
+ const canonicalValue = value ? getCanonicalDefaultCategoryName(String(value))?.toLowerCase() ?? null : null;
+ const hasCurrentValue = Boolean(value) && options.some((category) => {
+ const normalizedCategory = category.name.toLowerCase();
+ if (normalizedCategory === normalizedValue) {
+ return true;
+ }
+ return canonicalValue ? normalizedCategory === canonicalValue : false;
+ });
return (
(
))
)}
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
)}
@@ -559,6 +598,12 @@ export default function ContractForm({ mode }: Props) {
label={t("contracts.categoryNameLabel")}
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleDialogCreateCategory();
+ }
+ }}
fullWidth
/>
@@ -570,15 +615,8 @@ export default function ContractForm({ mode }: Props) {
{t("actions.cancel")}
@@ -190,7 +191,11 @@ export default function ContractsList() {
{contract.provider ?? "–"}
- {contract.category ?? "–"}
+
+ {contract.category
+ ? translateCategoryName(contract.category, i18n.language) || contract.category
+ : "–"}
+
{formatCurrency(contract.price, contract.currency ?? "EUR")}
{formatDate(contract.contractEndDate)}
diff --git a/frontend/src/routes/Settings.tsx b/frontend/src/routes/Settings.tsx
index b5dca52..63d4709 100644
--- a/frontend/src/routes/Settings.tsx
+++ b/frontend/src/routes/Settings.tsx
@@ -16,6 +16,7 @@ import {
Grid,
IconButton,
InputAdornment,
+ MenuItem,
List,
ListItem,
ListItemAvatar,
@@ -27,7 +28,7 @@ import {
Typography
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
@@ -47,6 +48,7 @@ import PageHeader from "../components/PageHeader";
import { useAuth } from "../contexts/AuthContext";
import { useSnackbar } from "../hooks/useSnackbar";
import { useTranslation } from "react-i18next";
+import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
interface HealthResponse {
status: string;
@@ -55,6 +57,8 @@ interface HealthResponse {
type FormValues = {
paperlessBaseUrl: string;
paperlessExternalUrl: string;
+ appExternalUrl: string;
+ appLocale: string;
paperlessToken: string;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
@@ -76,6 +80,8 @@ type FormValues = {
const defaultValues: FormValues = {
paperlessBaseUrl: "",
paperlessExternalUrl: "",
+ appExternalUrl: "",
+ appLocale: "de",
paperlessToken: "",
schedulerIntervalMinutes: 60,
alertDaysBefore: 30,
@@ -97,7 +103,7 @@ const defaultValues: FormValues = {
export default function SettingsPage() {
const { authEnabled } = useAuth();
const { showMessage } = useSnackbar();
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -159,6 +165,8 @@ export default function SettingsPage() {
const values: FormValues = {
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
+ appExternalUrl: settingsData.values.appExternalUrl ?? "",
+ appLocale: settingsData.values.appLocale ?? "de",
paperlessToken: "",
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
alertDaysBefore: settingsData.values.alertDaysBefore,
@@ -223,7 +231,8 @@ export default function SettingsPage() {
await refetchCategories();
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCategoryName("");
- showMessage(t("settings.categoryCreated", { name: category.name }), "success");
+ const displayName = translateCategoryName(category.name, i18n.language) || category.name;
+ showMessage(t("settings.categoryCreated", { name: displayName }), "success");
},
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryCreateError"), "error")
});
@@ -238,6 +247,38 @@ export default function SettingsPage() {
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryDeleteError"), "error")
});
+ const trimmedCategoryName = newCategoryName.trim();
+ const categoryExists = useMemo(() => {
+ if (!trimmedCategoryName) {
+ return false;
+ }
+ if (!categories) {
+ return false;
+ }
+ const normalizedInput = trimmedCategoryName.toLowerCase();
+ const canonicalInput = getCanonicalDefaultCategoryName(trimmedCategoryName)?.toLowerCase() ?? null;
+ return categories.some((category) => {
+ const normalizedName = category.name.toLowerCase();
+ return (
+ normalizedName === normalizedInput ||
+ (canonicalInput ? normalizedName === canonicalInput : false)
+ );
+ });
+ }, [categories, trimmedCategoryName]);
+
+ const handleAddCategory = useCallback(() => {
+ if (!trimmedCategoryName) {
+ showMessage(t("settings.categoryNameRequired"), "warning");
+ return;
+ }
+ if (categoryExists) {
+ const existingDisplayName = translateCategoryName(trimmedCategoryName, i18n.language) || trimmedCategoryName;
+ showMessage(t("settings.categoryExists", { name: existingDisplayName }), "info");
+ return;
+ }
+ createCategoryMutation.mutate(trimmedCategoryName);
+ }, [categoryExists, createCategoryMutation, i18n.language, showMessage, t, trimmedCategoryName]);
+
const settings = settingsData;
const icalSecret = settings?.icalSecret ?? null;
const icalUrl = useMemo(() => {
@@ -265,6 +306,12 @@ export default function SettingsPage() {
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
}
+ if (formValues.appExternalUrl !== initial.appExternalUrl) {
+ payload.appExternalUrl = trimOrNull(formValues.appExternalUrl);
+ }
+ if (formValues.appLocale !== initial.appLocale) {
+ payload.appLocale = formValues.appLocale;
+ }
if (removePaperlessToken) {
payload.paperlessToken = null;
@@ -521,19 +568,18 @@ export default function SettingsPage() {
label={t("settings.categoryName")}
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleAddCategory();
+ }
+ }}
fullWidth
/>
@@ -543,7 +589,7 @@ export default function SettingsPage() {
{categories.map((category) => (
deleteCategoryMutation.mutate(category.id)}
disabled={deleteCategoryMutation.isPending}
/>
@@ -579,6 +625,23 @@ export default function SettingsPage() {
placeholder={t("settings.paperlessExample")}
fullWidth
/>
+
+
+
+
+
();
+for (const category of DEFAULT_CATEGORY_DATA) {
+ translationsByNormalizedName.set(normalize(category.de), category);
+ translationsByNormalizedName.set(normalize(category.en), category);
+}
+
+export function getCanonicalDefaultCategoryName(input: string): string | null {
+ const normalized = normalize(input);
+ const match = translationsByNormalizedName.get(normalized);
+ return match ? match.de : null;
+}
+
+export function translateCategoryName(name: string | null | undefined, language: string): string {
+ if (!name) {
+ return "";
+ }
+ const normalized = normalize(name);
+ const match = translationsByNormalizedName.get(normalized);
+ if (!match) {
+ return name;
+ }
+
+ if (language.startsWith("de")) {
+ return match.de;
+ }
+ if (language.startsWith("en")) {
+ return match.en;
+ }
+ // For other languages we fall back to English to keep labels readable.
+ return match.en;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 1ac6250..640e3af 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -15,8 +15,12 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
- "types": ["vite/client"]
+ "types": ["vite/client"],
+ "baseUrl": ".",
+ "paths": {
+ "@shared/*": ["../shared/*"]
+ }
},
- "include": ["src"],
+ "include": ["src", "../shared/**/*.json"],
"references": [{ "path": "./tsconfig.node.json" }]
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 181a333..eba8f0f 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,10 +1,19 @@
import { defineConfig } from "vite";
+import { fileURLToPath, URL } from "node:url";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
+ resolve: {
+ alias: {
+ "@shared": fileURLToPath(new URL("../shared", import.meta.url))
+ }
+ },
server: {
port: 5173,
+ fs: {
+ allow: [fileURLToPath(new URL("..", import.meta.url))]
+ },
proxy: {
"/api": {
target: "http://localhost:8000",
diff --git a/shared/defaultCategories.json b/shared/defaultCategories.json
new file mode 100644
index 0000000..10f33b2
--- /dev/null
+++ b/shared/defaultCategories.json
@@ -0,0 +1,37 @@
+[
+ {
+ "key": "insurance",
+ "de": "Versicherung",
+ "en": "Insurance"
+ },
+ {
+ "key": "energy",
+ "de": "Strom & Energie",
+ "en": "Energy & Utilities"
+ },
+ {
+ "key": "internet",
+ "de": "Internet & Telefon",
+ "en": "Internet & Phone"
+ },
+ {
+ "key": "housing",
+ "de": "Miete & Wohnen",
+ "en": "Rent & Housing"
+ },
+ {
+ "key": "mobile",
+ "de": "Mobilfunk",
+ "en": "Mobile Contracts"
+ },
+ {
+ "key": "media",
+ "de": "Streaming & Medien",
+ "en": "Streaming & Media"
+ },
+ {
+ "key": "service",
+ "de": "Wartung & Service",
+ "en": "Maintenance & Service"
+ }
+]
diff --git a/src/categoriesStore.ts b/src/categoriesStore.ts
index f7beaf2..b1248a0 100644
--- a/src/categoriesStore.ts
+++ b/src/categoriesStore.ts
@@ -1,4 +1,5 @@
import db from "./db.js";
+import { DEFAULT_CATEGORY_NAMES, getCanonicalDefaultCategoryName } from "./categoryDefaults.js";
export interface Category {
id: number;
@@ -22,6 +23,16 @@ DELETE FROM categories
WHERE id = ?
`);
+const countStmt = db.prepare(`
+SELECT COUNT(*) as count
+FROM categories
+`);
+
+const seedInsertStmt = db.prepare(`
+INSERT OR IGNORE INTO categories (name)
+VALUES (?)
+`);
+
const findByNameStmt = db.prepare(`
SELECT id, name, created_at
FROM categories
@@ -34,13 +45,33 @@ FROM categories
WHERE id = ?
`);
+function seedDefaultCategoriesIfEmpty() {
+ const row = countStmt.get() as { count: number } | undefined;
+ const count = row?.count ?? 0;
+ if (count === 0) {
+ const insertDefaults = db.transaction((names: string[]) => {
+ for (const name of names) {
+ seedInsertStmt.run(name);
+ }
+ });
+ insertDefaults(DEFAULT_CATEGORY_NAMES);
+ }
+}
+
+seedDefaultCategoriesIfEmpty();
+
export function findCategoryByName(name: string): Category | null {
const row = findByNameStmt.get(name.trim());
return row ? mapCategoryRow(row) : null;
}
export function listCategories(): Category[] {
- return listStmt.all().map(mapCategoryRow);
+ let rows = listStmt.all();
+ if (rows.length === 0) {
+ seedDefaultCategoriesIfEmpty();
+ rows = listStmt.all();
+ }
+ return rows.map(mapCategoryRow);
}
export function createCategory(name: string): Category {
@@ -49,9 +80,23 @@ export function createCategory(name: string): Category {
throw new Error("Category name must not be empty");
}
- const existing = findByNameStmt.get(trimmed);
- if (existing) {
- return mapCategoryRow(existing);
+ const existingExact = findByNameStmt.get(trimmed);
+ if (existingExact) {
+ return mapCategoryRow(existingExact);
+ }
+
+ const canonical = getCanonicalDefaultCategoryName(trimmed);
+ if (canonical) {
+ const existingCanonical = findByNameStmt.get(canonical);
+ if (existingCanonical) {
+ return mapCategoryRow(existingCanonical);
+ }
+ const canonicalInfo = insertStmt.run(canonical);
+ const canonicalCreated = getByIdStmt.get(canonicalInfo.lastInsertRowid);
+ if (!canonicalCreated) {
+ throw new Error("Failed to create category");
+ }
+ return mapCategoryRow(canonicalCreated);
}
const info = insertStmt.run(trimmed);
diff --git a/src/categoryDefaults.ts b/src/categoryDefaults.ts
new file mode 100644
index 0000000..ef05efd
--- /dev/null
+++ b/src/categoryDefaults.ts
@@ -0,0 +1,43 @@
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+type DefaultCategory = {
+ key: string;
+ de: string;
+ en: string;
+};
+
+const currentFile = fileURLToPath(import.meta.url);
+const currentDir = dirname(currentFile);
+const jsonPath = resolve(currentDir, "../shared/defaultCategories.json");
+const fileContent = readFileSync(jsonPath, "utf-8");
+const DEFAULT_CATEGORY_DATA = JSON.parse(fileContent) as DefaultCategory[];
+
+function normalize(input: string): string {
+ return input.trim().toLowerCase();
+}
+
+export const DEFAULT_CATEGORY_NAMES = DEFAULT_CATEGORY_DATA.map((category) => category.de);
+
+export function getCanonicalDefaultCategoryName(input: string): string | null {
+ const normalized = normalize(input);
+ if (!normalized) {
+ return null;
+ }
+ const match = DEFAULT_CATEGORY_DATA.find((category) => {
+ return (
+ normalize(category.de) === normalized ||
+ normalize(category.en) === normalized
+ );
+ });
+ return match ? match.de : null;
+}
+
+export function isDefaultCategoryName(input: string): boolean {
+ return getCanonicalDefaultCategoryName(input) !== null;
+}
+
+export function getDefaultCategoryTranslations(): DefaultCategory[] {
+ return DEFAULT_CATEGORY_DATA.map((category) => ({ ...category }));
+}
diff --git a/src/config.ts b/src/config.ts
index b821410..6ebd856 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
+const supportedLocales = ["de", "en"] as const;
+
const configSchema = z.object({
port: z.coerce.number().min(1).max(65535).default(8000),
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
@@ -7,6 +9,8 @@ const configSchema = z.object({
paperlessBaseUrl: z.string().url().optional(),
paperlessToken: z.string().min(1).optional(),
paperlessExternalUrl: z.string().url().optional(),
+ appExternalUrl: z.string().url().optional(),
+ appLocale: z.enum(supportedLocales).default("de"),
schedulerIntervalMinutes: z.coerce.number().min(5).default(60),
alertDaysBefore: z.coerce.number().min(1).default(30),
mailServer: z.string().optional(),
@@ -41,6 +45,8 @@ const rawConfig = {
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL,
paperlessToken: process.env.PAPERLESS_TOKEN,
paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL,
+ appExternalUrl: process.env.APP_EXTERNAL_URL,
+ appLocale: process.env.APP_LOCALE,
schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES,
alertDaysBefore: process.env.ALERT_DAYS_BEFORE,
mailServer: process.env.MAIL_SERVER,
diff --git a/src/db.ts b/src/db.ts
index 6c328f7..15484c0 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import { config } from "./config.js";
import { createLogger } from "./logger.js";
+import { DEFAULT_CATEGORY_NAMES } from "./categoryDefaults.js";
const logger = createLogger(config.logLevel);
@@ -52,21 +53,11 @@ CREATE TABLE IF NOT EXISTS categories (
`);
-const defaultCategories = [
- "Versicherung",
- "Strom & Energie",
- "Internet & Telefon",
- "Miete & Wohnen",
- "Mobilfunk",
- "Streaming & Medien",
- "Wartung & Service"
-];
-
const categoryRow = db.prepare(`SELECT COUNT(*) as count FROM categories`).get() as { count: number } | undefined;
const categoryCount = categoryRow?.count ?? 0;
if (categoryCount === 0) {
const insertStmt = db.prepare(`INSERT OR IGNORE INTO categories (name) VALUES (?)`);
- for (const name of defaultCategories) {
+ for (const name of DEFAULT_CATEGORY_NAMES) {
insertStmt.run(name);
}
}
diff --git a/src/index.ts b/src/index.ts
index 4dbb78c..c0c08c3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -24,7 +24,7 @@ import {
import type { RuntimeSettings } from "./runtimeSettings.js";
import type { UpcomingDeadline } from "./types.js";
import { ContractPayload } from "./types.js";
-import { sendDeadlineNotifications, sendTestEmail, sendTestNtfy } from "./notifications.js";
+import { sendTestEmail, sendTestNtfy } from "./notifications.js";
import { contractCreateSchema, contractUpdateSchema } from "./validators.js";
import { formatDateAsICS } from "./utils.js";
@@ -34,6 +34,13 @@ function buildBaseAppUrl(req: Request): string {
return `${forwardedProto}://${forwardedHost}`.replace(/\/$/, "");
}
+function resolveAppBaseUrl(req: Request, runtime: RuntimeSettings): string {
+ if (runtime.appExternalUrl) {
+ return runtime.appExternalUrl.replace(/\/$/, "");
+ }
+ return buildBaseAppUrl(req);
+}
+
const logger = createLogger(config.logLevel);
const app = express();
@@ -64,6 +71,8 @@ const loginSchema = z.object({
const settingsUpdateSchema = z.object({
paperlessBaseUrl: z.string().url().nullable().optional(),
paperlessExternalUrl: z.string().url().nullable().optional(),
+ appExternalUrl: z.string().url().nullable().optional(),
+ appLocale: z.enum(["de", "en"]).optional(),
paperlessToken: z.string().min(1).nullable().optional(),
schedulerIntervalMinutes: z.coerce.number().min(5).max(1440).optional(),
alertDaysBefore: z.coerce.number().min(1).max(365).optional(),
@@ -92,6 +101,8 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
values: {
paperlessBaseUrl: runtime.paperlessBaseUrl,
paperlessExternalUrl: runtime.paperlessExternalUrl,
+ appExternalUrl: runtime.appExternalUrl,
+ appLocale: runtime.appLocale,
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
alertDaysBefore: runtime.alertDaysBefore,
mailServer: runtime.mailServer,
@@ -237,7 +248,7 @@ app.get("/calendar/feed.ics", (req, res) => {
const runtime = { ...getRuntimeSettings(), icalSecret: secret };
const deadlines = listUpcomingDeadlines(365);
const paperlessUrl = runtime.paperlessExternalUrl ?? runtime.paperlessBaseUrl ?? null;
- const baseAppUrl = buildBaseAppUrl(req);
+ const baseAppUrl = resolveAppBaseUrl(req, runtime);
const ics = buildIcsFeed(deadlines, paperlessUrl, baseAppUrl);
res.setHeader("Content-Type", "text/calendar; charset=utf-8");
@@ -259,6 +270,8 @@ app.get("/config", (_req, res) => {
databasePath: config.databasePath,
paperlessBaseUrl: runtime.paperlessBaseUrl,
paperlessExternalUrl: runtime.paperlessExternalUrl,
+ appExternalUrl: runtime.appExternalUrl,
+ appLocale: runtime.appLocale,
paperlessConfigured,
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
alertDaysBefore: runtime.alertDaysBefore,
@@ -291,6 +304,14 @@ app.put("/settings", (req, res) => {
if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) {
update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null;
}
+ if (Object.prototype.hasOwnProperty.call(payload, "appExternalUrl")) {
+ update.appExternalUrl = payload.appExternalUrl ?? null;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, "appLocale")) {
+ if (typeof payload.appLocale === "string") {
+ update.appLocale = payload.appLocale;
+ }
+ }
if (Object.prototype.hasOwnProperty.call(payload, "paperlessToken")) {
update.paperlessToken = payload.paperlessToken ?? null;
}
diff --git a/src/notifications.ts b/src/notifications.ts
index 9f9b2ee..6d5dadc 100644
--- a/src/notifications.ts
+++ b/src/notifications.ts
@@ -3,9 +3,16 @@ import nodemailer from "nodemailer";
import { config } from "./config.js";
import { createLogger } from "./logger.js";
import type { RuntimeSettings } from "./runtimeSettings.js";
+import type { UpcomingDeadline } from "./types.js";
const logger = createLogger(config.logLevel);
+type NotificationContent = {
+ subject: string;
+ body: string;
+ clickUrl?: string | null;
+};
+
async function sendEmail(subject: string, body: string, settings: RuntimeSettings): Promise {
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
logger.debug("Mail configuration incomplete; skipping email alert.");
@@ -36,21 +43,25 @@ async function sendEmail(subject: string, body: string, settings: RuntimeSetting
});
}
-async function sendNtfy(subject: string, body: string, settings: RuntimeSettings): Promise {
+async function sendNtfy(subject: string, body: string, settings: RuntimeSettings, clickUrl?: string | null): Promise {
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
logger.debug("ntfy configuration missing; skipping push notification.");
return;
}
- const url = `${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`;
+ const url = new URL(`${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`);
+ url.searchParams.set("title", subject);
const headers: Record = {
- Title: subject
+ "Content-Type": "text/plain; charset=utf-8"
};
if (settings.ntfyToken) {
headers.Authorization = `Bearer ${settings.ntfyToken}`;
}
if (settings.ntfyPriority) {
- headers.Priority = settings.ntfyPriority;
+ url.searchParams.set("priority", settings.ntfyPriority);
+ }
+ if (clickUrl) {
+ url.searchParams.set("click", clickUrl);
}
const response = await fetch(url, {
@@ -65,13 +76,12 @@ async function sendNtfy(subject: string, body: string, settings: RuntimeSettings
}
}
-export async function sendDeadlineNotifications(subject: string, lines: string[], settings: RuntimeSettings) {
- const message = lines.join("\n");
+export async function sendDeadlineNotifications(content: NotificationContent, settings: RuntimeSettings) {
const tasks: Array> = [];
if (settings.mailServer && settings.mailFrom && settings.mailTo) {
tasks.push(
- sendEmail(subject, message, settings).then(() => {
+ sendEmail(content.subject, content.body, settings).then(() => {
logger.info("Deadline alert email sent.");
})
);
@@ -79,7 +89,7 @@ export async function sendDeadlineNotifications(subject: string, lines: string[]
if (settings.ntfyServerUrl && settings.ntfyTopic) {
tasks.push(
- sendNtfy(subject, message, settings).then(() => {
+ sendNtfy(content.subject, content.body, settings, content.clickUrl).then(() => {
logger.info("Deadline alert pushed via ntfy.");
})
);
@@ -101,12 +111,153 @@ export async function sendTestEmail(settings: RuntimeSettings): Promise {
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
throw new Error("E-Mail-Konfiguration unvollständig");
}
- await sendEmail("Contracts Companion Test", "Dies ist eine Testbenachrichtigung.", settings);
+ const locale = resolveLocale(settings);
+ const subject = locale === "de" ? "Contracts Companion – Test" : "Contracts Companion – Test";
+ const body =
+ locale === "de"
+ ? "Dies ist eine Testbenachrichtigung des Contracts Companion."
+ : "This is a test notification from Contracts Companion.";
+ await sendEmail(subject, body, settings);
}
export async function sendTestNtfy(settings: RuntimeSettings): Promise {
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
throw new Error("ntfy-Konfiguration unvollständig");
}
- await sendNtfy("Contracts Companion Test", "Dies ist eine Testbenachrichtigung.", settings);
+ const locale = resolveLocale(settings);
+ const subject = locale === "de" ? "Contracts Companion – Test" : "Contracts Companion – Test";
+ const body =
+ locale === "de"
+ ? "Dies ist eine Testbenachrichtigung des Contracts Companion."
+ : "This is a test notification from Contracts Companion.";
+ await sendNtfy(subject, body, settings, settings.appExternalUrl ?? null);
+}
+
+function resolveLocale(settings: RuntimeSettings): "de" | "en" {
+ const value = settings.appLocale?.toLowerCase() ?? config.appLocale ?? "de";
+ if (value.startsWith("en")) {
+ return "en";
+ }
+ return "de";
+}
+
+function formatDateLabel(date: string | null | undefined, locale: "de" | "en"): string {
+ if (!date) {
+ return locale === "de" ? "Unbekanntes Datum" : "Unknown date";
+ }
+ const formatter = new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit"
+ });
+ return formatter.format(new Date(`${date}T00:00:00Z`));
+}
+
+function formatDaysLabel(days: number | null | undefined, locale: "de" | "en"): string {
+ if (days === null || days === undefined) {
+ return locale === "de" ? "Restlaufzeit unbekannt" : "Remaining days unknown";
+ }
+ if (days < 0) {
+ const overdue = Math.abs(days);
+ if (locale === "de") {
+ return overdue === 1 ? "Seit 1 Tag überfällig" : `Seit ${overdue} Tagen überfällig`;
+ }
+ return overdue === 1 ? "Overdue by 1 day" : `Overdue by ${overdue} days`;
+ }
+ if (days === 0) {
+ return locale === "de" ? "Heute fällig" : "Due today";
+ }
+ if (locale === "de") {
+ return days === 1 ? "Noch 1 Tag" : `Noch ${days} Tage`;
+ }
+ return days === 1 ? "1 day left" : `${days} days left`;
+}
+
+type LinkInfo = {
+ label: string;
+ url: string;
+};
+
+function buildLinks(
+ item: UpcomingDeadline,
+ appUrl: string | null,
+ paperlessUrl: string | null,
+ locale: "de" | "en"
+): LinkInfo[] {
+ const lines: LinkInfo[] = [];
+ if (appUrl) {
+ const label = locale === "de" ? "Vertrag" : "Contract";
+ lines.push({ label, url: `${appUrl.replace(/\/$/, "")}/contracts/${item.id}` });
+ }
+ if (paperlessUrl && item.paperlessDocumentId) {
+ const label = locale === "de" ? "Paperless-Dokument" : "Paperless document";
+ lines.push({ label, url: `${paperlessUrl.replace(/\/$/, "")}/documents/${item.paperlessDocumentId}` });
+ }
+ return lines;
+}
+
+export function composeDeadlineNotification(
+ deadlines: UpcomingDeadline[],
+ settings: RuntimeSettings
+): NotificationContent {
+ const locale = resolveLocale(settings);
+ const count = deadlines.length;
+ const subject = locale === "de"
+ ? (count === 1 ? "Eine Kündigungsfrist steht an" : `${count} Kündigungsfristen stehen an`)
+ : (count === 1 ? "Contract deadline due soon" : `${count} contract deadlines due soon`);
+
+ const header = locale === "de"
+ ? "🔔 Vertragswarnung"
+ : "🔔 Contract reminder";
+
+ const appUrl = settings.appExternalUrl ?? null;
+ const paperlessUrl = settings.paperlessExternalUrl ?? settings.paperlessBaseUrl ?? null;
+ let primaryLink: string | null = appUrl;
+
+ const entries = deadlines.map((item) => {
+ const lines: string[] = [];
+ lines.push(`• ${item.title} (#${item.id})`);
+ if (item.provider) {
+ lines.push(` ${locale === "de" ? "Anbieter" : "Provider"}: ${item.provider}`);
+ }
+ lines.push(
+ ` ${locale === "de" ? "Kündigen bis" : "Cancel by"}: ${formatDateLabel(
+ item.terminationDeadline,
+ locale
+ )} (${formatDaysLabel(item.daysUntilDeadline ?? null, locale)})`
+ );
+ const linkInfos = buildLinks(item, appUrl, paperlessUrl, locale);
+ if (!primaryLink) {
+ const contractLink = linkInfos.find((info) => info.url.includes("/contracts/"));
+ const fallback = linkInfos[0];
+ primaryLink = contractLink?.url ?? fallback?.url ?? null;
+ }
+ lines.push(...linkInfos.map((link) => ` ${link.label}: ${link.url}`));
+ return lines.join("\n");
+ });
+
+ const footer: string[] = [];
+ if (appUrl) {
+ footer.push(
+ `${locale === "de" ? "Zur Übersicht" : "Open dashboard"}: ${appUrl.replace(/\/$/, "")}`
+ );
+ } else {
+ footer.push(
+ locale === "de"
+ ? "Tipp: Hinterlege die externe URL der App in den Einstellungen, um Direktlinks zu erhalten."
+ : "Tip: Configure the app external URL in settings to enable direct links."
+ );
+ }
+
+ const bodyParts: string[] = [header];
+ const entriesBlock = entries.join("\n\n");
+ if (entriesBlock) {
+ bodyParts.push("", entriesBlock);
+ }
+ if (footer.length > 0) {
+ bodyParts.push("", ...footer);
+ }
+ const body = bodyParts.join("\n");
+
+ return { subject, body, clickUrl: primaryLink };
}
diff --git a/src/runtimeSettings.ts b/src/runtimeSettings.ts
index e5b6a46..6f0754c 100644
--- a/src/runtimeSettings.ts
+++ b/src/runtimeSettings.ts
@@ -12,6 +12,8 @@ import {
export interface RuntimeSettings {
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
+ appExternalUrl: string | null;
+ appLocale: string;
paperlessToken: string | null;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
@@ -65,6 +67,19 @@ function coerceString(value: unknown, fallback: string | null): string | null {
return fallback;
}
+function normalizeLocale(value: unknown, fallback: string): string {
+ if (typeof value === "string") {
+ const lower = value.toLowerCase();
+ if (lower.startsWith("de")) {
+ return "de";
+ }
+ if (lower.startsWith("en")) {
+ return "en";
+ }
+ }
+ return fallback;
+}
+
export function getRuntimeSettings(): RuntimeSettings {
const stored = listSettings();
@@ -78,6 +93,8 @@ export function getRuntimeSettings(): RuntimeSettings {
return {
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null),
+ appExternalUrl: coerceString(stored.appExternalUrl, config.appExternalUrl ?? null),
+ appLocale: normalizeLocale(stored.appLocale, config.appLocale),
paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null),
schedulerIntervalMinutes,
alertDaysBefore,
@@ -104,12 +121,16 @@ export function getRuntimeSettings(): RuntimeSettings {
export function updateRuntimeSettings(update: Partial): RuntimeSettings {
const keys = Object.keys(update) as SettingKey[];
for (const key of keys) {
- const value = update[key as keyof RuntimeSettings];
+ let value = update[key as keyof RuntimeSettings];
if (value === undefined || value === null || value === "") {
removeSetting(key);
continue;
}
+ if (key === "appLocale") {
+ value = normalizeLocale(value, config.appLocale);
+ }
+
if (numericKeys.has(key)) {
const numericValue = coerceNumber(value, 0);
setSetting(key, numericValue);
diff --git a/src/scheduler.ts b/src/scheduler.ts
index 75e2189..30615a9 100644
--- a/src/scheduler.ts
+++ b/src/scheduler.ts
@@ -1,7 +1,7 @@
import { config } from "./config.js";
import { listUpcomingDeadlines } from "./contractsStore.js";
import { createLogger } from "./logger.js";
-import { sendDeadlineNotifications } from "./notifications.js";
+import { composeDeadlineNotification, sendDeadlineNotifications } from "./notifications.js";
import { getRuntimeSettings } from "./runtimeSettings.js";
const logger = createLogger(config.logLevel);
@@ -62,11 +62,6 @@ export class DeadlineMonitor {
return;
}
- const lines = deadlines.map(
- (item) =>
- `${item.title} (#${item.id}) — cancel by ${item.terminationDeadline} (${item.daysUntilDeadline} days left)`
- );
-
for (const item of deadlines) {
logger.warn(
"Upcoming deadline: %s (provider=%s, documentId=%s, terminate by %s, days=%s)",
@@ -78,7 +73,8 @@ export class DeadlineMonitor {
);
}
- await sendDeadlineNotifications("Contract termination reminder", lines, settings);
+ const notification = composeDeadlineNotification(deadlines, settings);
+ await sendDeadlineNotifications(notification, settings);
}
}
diff --git a/src/settingsStore.ts b/src/settingsStore.ts
index 90aee53..dd81ba8 100644
--- a/src/settingsStore.ts
+++ b/src/settingsStore.ts
@@ -10,6 +10,8 @@ const listStmt = db.prepare("SELECT key, value FROM settings");
export type SettingKey =
| "paperlessBaseUrl"
| "paperlessExternalUrl"
+ | "appExternalUrl"
+ | "appLocale"
| "paperlessToken"
| "schedulerIntervalMinutes"
| "alertDaysBefore"