localization for ntfy and mail
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import db from "./db.js";
|
||||
import { DEFAULT_CATEGORY_NAMES, getCanonicalDefaultCategoryName } from "./categoryDefaults.js";
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
@@ -22,6 +23,16 @@ DELETE FROM categories
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const countStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM categories
|
||||
`);
|
||||
|
||||
const seedInsertStmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO categories (name)
|
||||
VALUES (?)
|
||||
`);
|
||||
|
||||
const findByNameStmt = db.prepare(`
|
||||
SELECT id, name, created_at
|
||||
FROM categories
|
||||
@@ -34,13 +45,33 @@ FROM categories
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
function seedDefaultCategoriesIfEmpty() {
|
||||
const row = countStmt.get() as { count: number } | undefined;
|
||||
const count = row?.count ?? 0;
|
||||
if (count === 0) {
|
||||
const insertDefaults = db.transaction((names: string[]) => {
|
||||
for (const name of names) {
|
||||
seedInsertStmt.run(name);
|
||||
}
|
||||
});
|
||||
insertDefaults(DEFAULT_CATEGORY_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
seedDefaultCategoriesIfEmpty();
|
||||
|
||||
export function findCategoryByName(name: string): Category | null {
|
||||
const row = findByNameStmt.get(name.trim());
|
||||
return row ? mapCategoryRow(row) : null;
|
||||
}
|
||||
|
||||
export function listCategories(): Category[] {
|
||||
return listStmt.all().map(mapCategoryRow);
|
||||
let rows = listStmt.all();
|
||||
if (rows.length === 0) {
|
||||
seedDefaultCategoriesIfEmpty();
|
||||
rows = listStmt.all();
|
||||
}
|
||||
return rows.map(mapCategoryRow);
|
||||
}
|
||||
|
||||
export function createCategory(name: string): Category {
|
||||
@@ -49,9 +80,23 @@ export function createCategory(name: string): Category {
|
||||
throw new Error("Category name must not be empty");
|
||||
}
|
||||
|
||||
const existing = findByNameStmt.get(trimmed);
|
||||
if (existing) {
|
||||
return mapCategoryRow(existing);
|
||||
const existingExact = findByNameStmt.get(trimmed);
|
||||
if (existingExact) {
|
||||
return mapCategoryRow(existingExact);
|
||||
}
|
||||
|
||||
const canonical = getCanonicalDefaultCategoryName(trimmed);
|
||||
if (canonical) {
|
||||
const existingCanonical = findByNameStmt.get(canonical);
|
||||
if (existingCanonical) {
|
||||
return mapCategoryRow(existingCanonical);
|
||||
}
|
||||
const canonicalInfo = insertStmt.run(canonical);
|
||||
const canonicalCreated = getByIdStmt.get(canonicalInfo.lastInsertRowid);
|
||||
if (!canonicalCreated) {
|
||||
throw new Error("Failed to create category");
|
||||
}
|
||||
return mapCategoryRow(canonicalCreated);
|
||||
}
|
||||
|
||||
const info = insertStmt.run(trimmed);
|
||||
|
||||
43
src/categoryDefaults.ts
Normal file
43
src/categoryDefaults.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type DefaultCategory = {
|
||||
key: string;
|
||||
de: string;
|
||||
en: string;
|
||||
};
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const currentDir = dirname(currentFile);
|
||||
const jsonPath = resolve(currentDir, "../shared/defaultCategories.json");
|
||||
const fileContent = readFileSync(jsonPath, "utf-8");
|
||||
const DEFAULT_CATEGORY_DATA = JSON.parse(fileContent) as DefaultCategory[];
|
||||
|
||||
function normalize(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORY_NAMES = DEFAULT_CATEGORY_DATA.map((category) => category.de);
|
||||
|
||||
export function getCanonicalDefaultCategoryName(input: string): string | null {
|
||||
const normalized = normalize(input);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const match = DEFAULT_CATEGORY_DATA.find((category) => {
|
||||
return (
|
||||
normalize(category.de) === normalized ||
|
||||
normalize(category.en) === normalized
|
||||
);
|
||||
});
|
||||
return match ? match.de : null;
|
||||
}
|
||||
|
||||
export function isDefaultCategoryName(input: string): boolean {
|
||||
return getCanonicalDefaultCategoryName(input) !== null;
|
||||
}
|
||||
|
||||
export function getDefaultCategoryTranslations(): DefaultCategory[] {
|
||||
return DEFAULT_CATEGORY_DATA.map((category) => ({ ...category }));
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const supportedLocales = ["de", "en"] as const;
|
||||
|
||||
const configSchema = z.object({
|
||||
port: z.coerce.number().min(1).max(65535).default(8000),
|
||||
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
@@ -7,6 +9,8 @@ const configSchema = z.object({
|
||||
paperlessBaseUrl: z.string().url().optional(),
|
||||
paperlessToken: z.string().min(1).optional(),
|
||||
paperlessExternalUrl: z.string().url().optional(),
|
||||
appExternalUrl: z.string().url().optional(),
|
||||
appLocale: z.enum(supportedLocales).default("de"),
|
||||
schedulerIntervalMinutes: z.coerce.number().min(5).default(60),
|
||||
alertDaysBefore: z.coerce.number().min(1).default(30),
|
||||
mailServer: z.string().optional(),
|
||||
@@ -41,6 +45,8 @@ const rawConfig = {
|
||||
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL,
|
||||
paperlessToken: process.env.PAPERLESS_TOKEN,
|
||||
paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL,
|
||||
appExternalUrl: process.env.APP_EXTERNAL_URL,
|
||||
appLocale: process.env.APP_LOCALE,
|
||||
schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES,
|
||||
alertDaysBefore: process.env.ALERT_DAYS_BEFORE,
|
||||
mailServer: process.env.MAIL_SERVER,
|
||||
|
||||
13
src/db.ts
13
src/db.ts
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { DEFAULT_CATEGORY_NAMES } from "./categoryDefaults.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
@@ -52,21 +53,11 @@ CREATE TABLE IF NOT EXISTS categories (
|
||||
`);
|
||||
|
||||
|
||||
const defaultCategories = [
|
||||
"Versicherung",
|
||||
"Strom & Energie",
|
||||
"Internet & Telefon",
|
||||
"Miete & Wohnen",
|
||||
"Mobilfunk",
|
||||
"Streaming & Medien",
|
||||
"Wartung & Service"
|
||||
];
|
||||
|
||||
const categoryRow = db.prepare(`SELECT COUNT(*) as count FROM categories`).get() as { count: number } | undefined;
|
||||
const categoryCount = categoryRow?.count ?? 0;
|
||||
if (categoryCount === 0) {
|
||||
const insertStmt = db.prepare(`INSERT OR IGNORE INTO categories (name) VALUES (?)`);
|
||||
for (const name of defaultCategories) {
|
||||
for (const name of DEFAULT_CATEGORY_NAMES) {
|
||||
insertStmt.run(name);
|
||||
}
|
||||
}
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -24,7 +24,7 @@ import {
|
||||
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||
import type { UpcomingDeadline } from "./types.js";
|
||||
import { ContractPayload } from "./types.js";
|
||||
import { sendDeadlineNotifications, sendTestEmail, sendTestNtfy } from "./notifications.js";
|
||||
import { sendTestEmail, sendTestNtfy } from "./notifications.js";
|
||||
import { contractCreateSchema, contractUpdateSchema } from "./validators.js";
|
||||
import { formatDateAsICS } from "./utils.js";
|
||||
|
||||
@@ -34,6 +34,13 @@ function buildBaseAppUrl(req: Request): string {
|
||||
return `${forwardedProto}://${forwardedHost}`.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function resolveAppBaseUrl(req: Request, runtime: RuntimeSettings): string {
|
||||
if (runtime.appExternalUrl) {
|
||||
return runtime.appExternalUrl.replace(/\/$/, "");
|
||||
}
|
||||
return buildBaseAppUrl(req);
|
||||
}
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
const app = express();
|
||||
|
||||
@@ -64,6 +71,8 @@ const loginSchema = z.object({
|
||||
const settingsUpdateSchema = z.object({
|
||||
paperlessBaseUrl: z.string().url().nullable().optional(),
|
||||
paperlessExternalUrl: z.string().url().nullable().optional(),
|
||||
appExternalUrl: z.string().url().nullable().optional(),
|
||||
appLocale: z.enum(["de", "en"]).optional(),
|
||||
paperlessToken: z.string().min(1).nullable().optional(),
|
||||
schedulerIntervalMinutes: z.coerce.number().min(5).max(1440).optional(),
|
||||
alertDaysBefore: z.coerce.number().min(1).max(365).optional(),
|
||||
@@ -92,6 +101,8 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
|
||||
values: {
|
||||
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
||||
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
||||
appExternalUrl: runtime.appExternalUrl,
|
||||
appLocale: runtime.appLocale,
|
||||
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
||||
alertDaysBefore: runtime.alertDaysBefore,
|
||||
mailServer: runtime.mailServer,
|
||||
@@ -237,7 +248,7 @@ app.get("/calendar/feed.ics", (req, res) => {
|
||||
const runtime = { ...getRuntimeSettings(), icalSecret: secret };
|
||||
const deadlines = listUpcomingDeadlines(365);
|
||||
const paperlessUrl = runtime.paperlessExternalUrl ?? runtime.paperlessBaseUrl ?? null;
|
||||
const baseAppUrl = buildBaseAppUrl(req);
|
||||
const baseAppUrl = resolveAppBaseUrl(req, runtime);
|
||||
const ics = buildIcsFeed(deadlines, paperlessUrl, baseAppUrl);
|
||||
|
||||
res.setHeader("Content-Type", "text/calendar; charset=utf-8");
|
||||
@@ -259,6 +270,8 @@ app.get("/config", (_req, res) => {
|
||||
databasePath: config.databasePath,
|
||||
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
||||
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
||||
appExternalUrl: runtime.appExternalUrl,
|
||||
appLocale: runtime.appLocale,
|
||||
paperlessConfigured,
|
||||
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
||||
alertDaysBefore: runtime.alertDaysBefore,
|
||||
@@ -291,6 +304,14 @@ app.put("/settings", (req, res) => {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) {
|
||||
update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "appExternalUrl")) {
|
||||
update.appExternalUrl = payload.appExternalUrl ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "appLocale")) {
|
||||
if (typeof payload.appLocale === "string") {
|
||||
update.appLocale = payload.appLocale;
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessToken")) {
|
||||
update.paperlessToken = payload.paperlessToken ?? null;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,16 @@ import nodemailer from "nodemailer";
|
||||
import { config } from "./config.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||
import type { UpcomingDeadline } from "./types.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
type NotificationContent = {
|
||||
subject: string;
|
||||
body: string;
|
||||
clickUrl?: string | null;
|
||||
};
|
||||
|
||||
async function sendEmail(subject: string, body: string, settings: RuntimeSettings): Promise<void> {
|
||||
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
|
||||
logger.debug("Mail configuration incomplete; skipping email alert.");
|
||||
@@ -36,21 +43,25 @@ async function sendEmail(subject: string, body: string, settings: RuntimeSetting
|
||||
});
|
||||
}
|
||||
|
||||
async function sendNtfy(subject: string, body: string, settings: RuntimeSettings): Promise<void> {
|
||||
async function sendNtfy(subject: string, body: string, settings: RuntimeSettings, clickUrl?: string | null): Promise<void> {
|
||||
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
||||
logger.debug("ntfy configuration missing; skipping push notification.");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`;
|
||||
const url = new URL(`${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`);
|
||||
url.searchParams.set("title", subject);
|
||||
const headers: Record<string, string> = {
|
||||
Title: subject
|
||||
"Content-Type": "text/plain; charset=utf-8"
|
||||
};
|
||||
if (settings.ntfyToken) {
|
||||
headers.Authorization = `Bearer ${settings.ntfyToken}`;
|
||||
}
|
||||
if (settings.ntfyPriority) {
|
||||
headers.Priority = settings.ntfyPriority;
|
||||
url.searchParams.set("priority", settings.ntfyPriority);
|
||||
}
|
||||
if (clickUrl) {
|
||||
url.searchParams.set("click", clickUrl);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -65,13 +76,12 @@ async function sendNtfy(subject: string, body: string, settings: RuntimeSettings
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDeadlineNotifications(subject: string, lines: string[], settings: RuntimeSettings) {
|
||||
const message = lines.join("\n");
|
||||
export async function sendDeadlineNotifications(content: NotificationContent, settings: RuntimeSettings) {
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
||||
if (settings.mailServer && settings.mailFrom && settings.mailTo) {
|
||||
tasks.push(
|
||||
sendEmail(subject, message, settings).then(() => {
|
||||
sendEmail(content.subject, content.body, settings).then(() => {
|
||||
logger.info("Deadline alert email sent.");
|
||||
})
|
||||
);
|
||||
@@ -79,7 +89,7 @@ export async function sendDeadlineNotifications(subject: string, lines: string[]
|
||||
|
||||
if (settings.ntfyServerUrl && settings.ntfyTopic) {
|
||||
tasks.push(
|
||||
sendNtfy(subject, message, settings).then(() => {
|
||||
sendNtfy(content.subject, content.body, settings, content.clickUrl).then(() => {
|
||||
logger.info("Deadline alert pushed via ntfy.");
|
||||
})
|
||||
);
|
||||
@@ -101,12 +111,153 @@ export async function sendTestEmail(settings: RuntimeSettings): Promise<void> {
|
||||
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
|
||||
throw new Error("E-Mail-Konfiguration unvollständig");
|
||||
}
|
||||
await sendEmail("Contracts Companion Test", "Dies ist eine Testbenachrichtigung.", settings);
|
||||
const locale = resolveLocale(settings);
|
||||
const subject = locale === "de" ? "Contracts Companion – Test" : "Contracts Companion – Test";
|
||||
const body =
|
||||
locale === "de"
|
||||
? "Dies ist eine Testbenachrichtigung des Contracts Companion."
|
||||
: "This is a test notification from Contracts Companion.";
|
||||
await sendEmail(subject, body, settings);
|
||||
}
|
||||
|
||||
export async function sendTestNtfy(settings: RuntimeSettings): Promise<void> {
|
||||
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
||||
throw new Error("ntfy-Konfiguration unvollständig");
|
||||
}
|
||||
await sendNtfy("Contracts Companion Test", "Dies ist eine Testbenachrichtigung.", settings);
|
||||
const locale = resolveLocale(settings);
|
||||
const subject = locale === "de" ? "Contracts Companion – Test" : "Contracts Companion – Test";
|
||||
const body =
|
||||
locale === "de"
|
||||
? "Dies ist eine Testbenachrichtigung des Contracts Companion."
|
||||
: "This is a test notification from Contracts Companion.";
|
||||
await sendNtfy(subject, body, settings, settings.appExternalUrl ?? null);
|
||||
}
|
||||
|
||||
function resolveLocale(settings: RuntimeSettings): "de" | "en" {
|
||||
const value = settings.appLocale?.toLowerCase() ?? config.appLocale ?? "de";
|
||||
if (value.startsWith("en")) {
|
||||
return "en";
|
||||
}
|
||||
return "de";
|
||||
}
|
||||
|
||||
function formatDateLabel(date: string | null | undefined, locale: "de" | "en"): string {
|
||||
if (!date) {
|
||||
return locale === "de" ? "Unbekanntes Datum" : "Unknown date";
|
||||
}
|
||||
const formatter = new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "2-digit"
|
||||
});
|
||||
return formatter.format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
function formatDaysLabel(days: number | null | undefined, locale: "de" | "en"): string {
|
||||
if (days === null || days === undefined) {
|
||||
return locale === "de" ? "Restlaufzeit unbekannt" : "Remaining days unknown";
|
||||
}
|
||||
if (days < 0) {
|
||||
const overdue = Math.abs(days);
|
||||
if (locale === "de") {
|
||||
return overdue === 1 ? "Seit 1 Tag überfällig" : `Seit ${overdue} Tagen überfällig`;
|
||||
}
|
||||
return overdue === 1 ? "Overdue by 1 day" : `Overdue by ${overdue} days`;
|
||||
}
|
||||
if (days === 0) {
|
||||
return locale === "de" ? "Heute fällig" : "Due today";
|
||||
}
|
||||
if (locale === "de") {
|
||||
return days === 1 ? "Noch 1 Tag" : `Noch ${days} Tage`;
|
||||
}
|
||||
return days === 1 ? "1 day left" : `${days} days left`;
|
||||
}
|
||||
|
||||
type LinkInfo = {
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function buildLinks(
|
||||
item: UpcomingDeadline,
|
||||
appUrl: string | null,
|
||||
paperlessUrl: string | null,
|
||||
locale: "de" | "en"
|
||||
): LinkInfo[] {
|
||||
const lines: LinkInfo[] = [];
|
||||
if (appUrl) {
|
||||
const label = locale === "de" ? "Vertrag" : "Contract";
|
||||
lines.push({ label, url: `${appUrl.replace(/\/$/, "")}/contracts/${item.id}` });
|
||||
}
|
||||
if (paperlessUrl && item.paperlessDocumentId) {
|
||||
const label = locale === "de" ? "Paperless-Dokument" : "Paperless document";
|
||||
lines.push({ label, url: `${paperlessUrl.replace(/\/$/, "")}/documents/${item.paperlessDocumentId}` });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function composeDeadlineNotification(
|
||||
deadlines: UpcomingDeadline[],
|
||||
settings: RuntimeSettings
|
||||
): NotificationContent {
|
||||
const locale = resolveLocale(settings);
|
||||
const count = deadlines.length;
|
||||
const subject = locale === "de"
|
||||
? (count === 1 ? "Eine Kündigungsfrist steht an" : `${count} Kündigungsfristen stehen an`)
|
||||
: (count === 1 ? "Contract deadline due soon" : `${count} contract deadlines due soon`);
|
||||
|
||||
const header = locale === "de"
|
||||
? "🔔 Vertragswarnung"
|
||||
: "🔔 Contract reminder";
|
||||
|
||||
const appUrl = settings.appExternalUrl ?? null;
|
||||
const paperlessUrl = settings.paperlessExternalUrl ?? settings.paperlessBaseUrl ?? null;
|
||||
let primaryLink: string | null = appUrl;
|
||||
|
||||
const entries = deadlines.map((item) => {
|
||||
const lines: string[] = [];
|
||||
lines.push(`• ${item.title} (#${item.id})`);
|
||||
if (item.provider) {
|
||||
lines.push(` ${locale === "de" ? "Anbieter" : "Provider"}: ${item.provider}`);
|
||||
}
|
||||
lines.push(
|
||||
` ${locale === "de" ? "Kündigen bis" : "Cancel by"}: ${formatDateLabel(
|
||||
item.terminationDeadline,
|
||||
locale
|
||||
)} (${formatDaysLabel(item.daysUntilDeadline ?? null, locale)})`
|
||||
);
|
||||
const linkInfos = buildLinks(item, appUrl, paperlessUrl, locale);
|
||||
if (!primaryLink) {
|
||||
const contractLink = linkInfos.find((info) => info.url.includes("/contracts/"));
|
||||
const fallback = linkInfos[0];
|
||||
primaryLink = contractLink?.url ?? fallback?.url ?? null;
|
||||
}
|
||||
lines.push(...linkInfos.map((link) => ` ${link.label}: ${link.url}`));
|
||||
return lines.join("\n");
|
||||
});
|
||||
|
||||
const footer: string[] = [];
|
||||
if (appUrl) {
|
||||
footer.push(
|
||||
`${locale === "de" ? "Zur Übersicht" : "Open dashboard"}: ${appUrl.replace(/\/$/, "")}`
|
||||
);
|
||||
} else {
|
||||
footer.push(
|
||||
locale === "de"
|
||||
? "Tipp: Hinterlege die externe URL der App in den Einstellungen, um Direktlinks zu erhalten."
|
||||
: "Tip: Configure the app external URL in settings to enable direct links."
|
||||
);
|
||||
}
|
||||
|
||||
const bodyParts: string[] = [header];
|
||||
const entriesBlock = entries.join("\n\n");
|
||||
if (entriesBlock) {
|
||||
bodyParts.push("", entriesBlock);
|
||||
}
|
||||
if (footer.length > 0) {
|
||||
bodyParts.push("", ...footer);
|
||||
}
|
||||
const body = bodyParts.join("\n");
|
||||
|
||||
return { subject, body, clickUrl: primaryLink };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
export interface RuntimeSettings {
|
||||
paperlessBaseUrl: string | null;
|
||||
paperlessExternalUrl: string | null;
|
||||
appExternalUrl: string | null;
|
||||
appLocale: string;
|
||||
paperlessToken: string | null;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
@@ -65,6 +67,19 @@ function coerceString(value: unknown, fallback: string | null): string | null {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeLocale(value: unknown, fallback: string): string {
|
||||
if (typeof value === "string") {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith("de")) {
|
||||
return "de";
|
||||
}
|
||||
if (lower.startsWith("en")) {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getRuntimeSettings(): RuntimeSettings {
|
||||
const stored = listSettings();
|
||||
|
||||
@@ -78,6 +93,8 @@ export function getRuntimeSettings(): RuntimeSettings {
|
||||
return {
|
||||
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
|
||||
paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null),
|
||||
appExternalUrl: coerceString(stored.appExternalUrl, config.appExternalUrl ?? null),
|
||||
appLocale: normalizeLocale(stored.appLocale, config.appLocale),
|
||||
paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null),
|
||||
schedulerIntervalMinutes,
|
||||
alertDaysBefore,
|
||||
@@ -104,12 +121,16 @@ export function getRuntimeSettings(): RuntimeSettings {
|
||||
export function updateRuntimeSettings(update: Partial<RuntimeSettings>): RuntimeSettings {
|
||||
const keys = Object.keys(update) as SettingKey[];
|
||||
for (const key of keys) {
|
||||
const value = update[key as keyof RuntimeSettings];
|
||||
let value = update[key as keyof RuntimeSettings];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
removeSetting(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "appLocale") {
|
||||
value = normalizeLocale(value, config.appLocale);
|
||||
}
|
||||
|
||||
if (numericKeys.has(key)) {
|
||||
const numericValue = coerceNumber(value, 0);
|
||||
setSetting(key, numericValue);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from "./config.js";
|
||||
import { listUpcomingDeadlines } from "./contractsStore.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { sendDeadlineNotifications } from "./notifications.js";
|
||||
import { composeDeadlineNotification, sendDeadlineNotifications } from "./notifications.js";
|
||||
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
@@ -62,11 +62,6 @@ export class DeadlineMonitor {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = deadlines.map(
|
||||
(item) =>
|
||||
`${item.title} (#${item.id}) — cancel by ${item.terminationDeadline} (${item.daysUntilDeadline} days left)`
|
||||
);
|
||||
|
||||
for (const item of deadlines) {
|
||||
logger.warn(
|
||||
"Upcoming deadline: %s (provider=%s, documentId=%s, terminate by %s, days=%s)",
|
||||
@@ -78,7 +73,8 @@ export class DeadlineMonitor {
|
||||
);
|
||||
}
|
||||
|
||||
await sendDeadlineNotifications("Contract termination reminder", lines, settings);
|
||||
const notification = composeDeadlineNotification(deadlines, settings);
|
||||
await sendDeadlineNotifications(notification, settings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const listStmt = db.prepare("SELECT key, value FROM settings");
|
||||
export type SettingKey =
|
||||
| "paperlessBaseUrl"
|
||||
| "paperlessExternalUrl"
|
||||
| "appExternalUrl"
|
||||
| "appLocale"
|
||||
| "paperlessToken"
|
||||
| "schedulerIntervalMinutes"
|
||||
| "alertDaysBefore"
|
||||
|
||||
Reference in New Issue
Block a user