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