localization for ntfy and mail

This commit is contained in:
MDeeApp
2025-10-11 19:34:54 +02:00
parent 17e094e8ac
commit adc4cfbee8
25 changed files with 609 additions and 74 deletions

View File

@@ -10,6 +10,7 @@ COPY package*.json tsconfig.json ./
RUN npm install
COPY src ./src
COPY shared ./shared
RUN npm run build

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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>

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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
View File

@@ -0,0 +1,10 @@
declare module "@shared/defaultCategories.json" {
interface DefaultCategory {
key: string;
de: string;
en: string;
}
const categories: DefaultCategory[];
export default categories;
}

View 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;
}

View File

@@ -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" }]
}

View File

@@ -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",

View 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"
}
]

View File

@@ -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
View 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 }));
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -10,6 +10,8 @@ const listStmt = db.prepare("SELECT key, value FROM settings");
export type SettingKey =
| "paperlessBaseUrl"
| "paperlessExternalUrl"
| "appExternalUrl"
| "appLocale"
| "paperlessToken"
| "schedulerIntervalMinutes"
| "alertDaysBefore"