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

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