localization for ntfy and mail
This commit is contained in:
@@ -10,6 +10,7 @@ COPY package*.json tsconfig.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY src ./src
|
||||
COPY shared ./shared
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 <Typography>{t("contractForm.loadError")}</Typography>;
|
||||
@@ -89,7 +93,7 @@ export default function ContractDetail() {
|
||||
<Detail label={t("contractDetail.notice")} value={terminationValue} />
|
||||
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
|
||||
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
|
||||
<Detail label={t("contractDetail.category")} value={contract.category ?? "–"} />
|
||||
<Detail label={t("contractDetail.category")} value={categoryValue} />
|
||||
<Detail label={t("contractDetail.notes")} value={notesValue} />
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<TextField
|
||||
label={t("contractForm.fields.category")}
|
||||
@@ -362,6 +400,7 @@ export default function ContractForm({ mode }: Props) {
|
||||
field.onChange(nextValue);
|
||||
}}
|
||||
SelectProps={{ displayEmpty: true }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
disabled={loadingCategories}
|
||||
error={Boolean(categoriesError)}
|
||||
helperText={categoriesError ? t("contracts.categoryLoadError") : undefined}
|
||||
@@ -380,13 +419,13 @@ export default function ContractForm({ mode }: Props) {
|
||||
) : (
|
||||
options.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
{translateCategoryName(category.name, i18n.language) || category.name}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
|
||||
<MenuItem value={value as string}>
|
||||
{String(value)}
|
||||
{translateCategoryName(String(value), i18n.language) || String(value)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
|
||||
@@ -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
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -570,15 +615,8 @@ export default function ContractForm({ mode }: Props) {
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("contracts.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
onClick={handleDialogCreateCategory}
|
||||
disabled={createCategoryMutation.isPending || !trimmedDialogCategoryName}
|
||||
variant="contained"
|
||||
>
|
||||
{t("contracts.categoryCreate")}
|
||||
|
||||
@@ -34,12 +34,13 @@ import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { Contract } from "../types";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
import { translateCategoryName } from "../utils/categories";
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
||||
|
||||
const {
|
||||
@@ -72,7 +73,7 @@ export default function ContractsList() {
|
||||
return normalizedContracts.filter((contract) => {
|
||||
const searchMatch =
|
||||
!search ||
|
||||
[contract.title, contract.provider, contract.notes, contract.category]
|
||||
[contract.title, contract.provider, contract.notes, contract.category, translateCategoryName(contract.category ?? "", i18n.language)]
|
||||
.filter(Boolean)
|
||||
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
@@ -130,7 +131,7 @@ export default function ContractsList() {
|
||||
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
||||
{(categoryOptions ?? []).map((item) => (
|
||||
<MenuItem key={item.id} value={item.name}>
|
||||
{item.name}
|
||||
{translateCategoryName(item.name, i18n.language) || item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
@@ -190,7 +191,11 @@ export default function ContractsList() {
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{contract.provider ?? "–"}</TableCell>
|
||||
<TableCell>{contract.category ?? "–"}</TableCell>
|
||||
<TableCell>
|
||||
{contract.category
|
||||
? translateCategoryName(contract.category, i18n.language) || contract.category
|
||||
: "–"}
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
|
||||
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("settings.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
onClick={handleAddCategory}
|
||||
disabled={createCategoryMutation.isPending || !trimmedCategoryName}
|
||||
>
|
||||
{t("settings.categoryAdd")}
|
||||
</Button>
|
||||
@@ -543,7 +589,7 @@ export default function SettingsPage() {
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
label={translateCategoryName(category.name, i18n.language) || category.name}
|
||||
onDelete={() => deleteCategoryMutation.mutate(category.id)}
|
||||
disabled={deleteCategoryMutation.isPending}
|
||||
/>
|
||||
@@ -579,6 +625,23 @@ export default function SettingsPage() {
|
||||
placeholder={t("settings.paperlessExample")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.appExternalUrl")}
|
||||
{...register("appExternalUrl")}
|
||||
placeholder={t("settings.appExternalExample")}
|
||||
fullWidth
|
||||
helperText={t("settings.appExternalUrlHelp")}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label={t("settings.appLocaleLabel")}
|
||||
{...register("appLocale")}
|
||||
fullWidth
|
||||
helperText={t("settings.appLocaleHelp")}
|
||||
>
|
||||
<MenuItem value="de">{t("settings.appLocaleGerman")}</MenuItem>
|
||||
<MenuItem value="en">{t("settings.appLocaleEnglish")}</MenuItem>
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
|
||||
|
||||
10
frontend/src/types/shared.d.ts
vendored
Normal file
10
frontend/src/types/shared.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module "@shared/defaultCategories.json" {
|
||||
interface DefaultCategory {
|
||||
key: string;
|
||||
de: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
const categories: DefaultCategory[];
|
||||
export default categories;
|
||||
}
|
||||
45
frontend/src/utils/categories.ts
Normal file
45
frontend/src/utils/categories.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import defaultCategories from "@shared/defaultCategories.json";
|
||||
|
||||
type DefaultCategory = {
|
||||
key: string;
|
||||
de: string;
|
||||
en: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CATEGORY_DATA = defaultCategories as DefaultCategory[];
|
||||
|
||||
function normalize(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const translationsByNormalizedName = new Map<string, DefaultCategory>();
|
||||
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;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
shared/defaultCategories.json
Normal file
37
shared/defaultCategories.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
|
||||
43
src/categoryDefaults.ts
Normal file
43
src/categoryDefaults.ts
Normal file
@@ -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 }));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
13
src/db.ts
13
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);
|
||||
}
|
||||
}
|
||||
|
||||
25
src/index.ts
25
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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
async function sendNtfy(subject: string, body: string, settings: RuntimeSettings, clickUrl?: string | null): Promise<void> {
|
||||
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<string, string> = {
|
||||
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<Promise<void>> = [];
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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>): 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const listStmt = db.prepare("SELECT key, value FROM settings");
|
||||
export type SettingKey =
|
||||
| "paperlessBaseUrl"
|
||||
| "paperlessExternalUrl"
|
||||
| "appExternalUrl"
|
||||
| "appLocale"
|
||||
| "paperlessToken"
|
||||
| "schedulerIntervalMinutes"
|
||||
| "alertDaysBefore"
|
||||
|
||||
Reference in New Issue
Block a user