initial
This commit is contained in:
122
src/auth.ts
Normal file
122
src/auth.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
const TOKEN_AUDIENCE = "paperless-contract-companion";
|
||||
const TOKEN_ISSUER = "paperless-contract-companion-service";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenPayload extends jwt.JwtPayload {
|
||||
sub: string;
|
||||
role: "admin";
|
||||
}
|
||||
|
||||
function safeCompare(expected: string | undefined, provided: string | undefined): boolean {
|
||||
if (typeof expected !== "string" || typeof provided !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
function getCurrentCredentials(): { username: string | null; password: string | null } {
|
||||
const runtime = getRuntimeSettings();
|
||||
const username = runtime.authUsername ?? config.authUsername ?? null;
|
||||
const password = runtime.authPassword ?? config.authPassword ?? null;
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
const { username, password } = getCurrentCredentials();
|
||||
return Boolean(username && password && config.authJwtSecret);
|
||||
}
|
||||
|
||||
function ensureAuthConfigured() {
|
||||
if (!config.authJwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET must be set when authentication is enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
if (!isAuthEnabled()) {
|
||||
logger.warn("Authentication disabled; all credentials are accepted.");
|
||||
return true;
|
||||
}
|
||||
|
||||
const { username: expectedUsername, password: expectedPassword } = getCurrentCredentials();
|
||||
|
||||
return safeCompare(expectedUsername ?? undefined, username) && safeCompare(expectedPassword ?? undefined, password);
|
||||
}
|
||||
|
||||
export function createAccessToken(username: string): { token: string; expiresAt: string } {
|
||||
ensureAuthConfigured();
|
||||
|
||||
const expiresInSeconds = config.authTokenExpiresInHours * 60 * 60;
|
||||
|
||||
const payload: TokenPayload = {
|
||||
sub: username,
|
||||
role: "admin",
|
||||
iss: TOKEN_ISSUER,
|
||||
aud: TOKEN_AUDIENCE
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, config.authJwtSecret!, {
|
||||
expiresIn: expiresInSeconds,
|
||||
algorithm: "HS256"
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function authenticateRequest(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
if (!isAuthEnabled()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authorization = req.get("authorization");
|
||||
|
||||
if (!authorization || !authorization.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing or invalid authorization header" });
|
||||
}
|
||||
|
||||
const token = authorization.slice("Bearer ".length);
|
||||
|
||||
try {
|
||||
ensureAuthConfigured();
|
||||
const payload = jwt.verify(token, config.authJwtSecret!, {
|
||||
audience: TOKEN_AUDIENCE,
|
||||
issuer: TOKEN_ISSUER,
|
||||
algorithms: ["HS256"]
|
||||
}) as TokenPayload;
|
||||
|
||||
req.user = { username: payload.sub };
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.warn("Failed to authenticate request: %s", (error as Error).message);
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
66
src/config.ts
Normal file
66
src/config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const configSchema = z.object({
|
||||
port: z.coerce.number().min(1).max(65535).default(8000),
|
||||
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
databasePath: z.string().default("./data/contracts.db"),
|
||||
paperlessBaseUrl: z.string().url().optional(),
|
||||
paperlessToken: z.string().min(1).optional(),
|
||||
paperlessExternalUrl: z.string().url().optional(),
|
||||
schedulerIntervalMinutes: z.coerce.number().min(5).default(60),
|
||||
alertDaysBefore: z.coerce.number().min(1).default(30),
|
||||
mailServer: z.string().optional(),
|
||||
mailPort: z.coerce.number().min(1).max(65535).default(587),
|
||||
mailUsername: z.string().optional(),
|
||||
mailPassword: z.string().optional(),
|
||||
mailUseTls: z.coerce.boolean().default(true),
|
||||
mailFrom: z.string().email().optional(),
|
||||
mailTo: z.string().email().optional(),
|
||||
authUsername: z.string().optional(),
|
||||
authPassword: z.string().optional(),
|
||||
authJwtSecret: z.string().optional(),
|
||||
authTokenExpiresInHours: z.coerce.number().min(1).max(168).default(12),
|
||||
ntfyServerUrl: z.string().url().optional(),
|
||||
ntfyTopic: z.string().min(1).optional(),
|
||||
ntfyToken: z.string().optional(),
|
||||
ntfyPriority: z.string().optional(),
|
||||
icalSecret: z.string().optional()
|
||||
});
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
const rawConfig = {
|
||||
port: process.env.PORT,
|
||||
logLevel: process.env.LOG_LEVEL,
|
||||
databasePath: process.env.DATABASE_PATH,
|
||||
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL,
|
||||
paperlessToken: process.env.PAPERLESS_TOKEN,
|
||||
paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL,
|
||||
schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES,
|
||||
alertDaysBefore: process.env.ALERT_DAYS_BEFORE,
|
||||
mailServer: process.env.MAIL_SERVER,
|
||||
mailPort: process.env.MAIL_PORT,
|
||||
mailUsername: process.env.MAIL_USERNAME,
|
||||
mailPassword: process.env.MAIL_PASSWORD,
|
||||
mailUseTls: parseBoolean(process.env.MAIL_USE_TLS, true),
|
||||
mailFrom: process.env.MAIL_FROM,
|
||||
mailTo: process.env.MAIL_TO,
|
||||
authUsername: process.env.AUTH_USERNAME,
|
||||
authPassword: process.env.AUTH_PASSWORD,
|
||||
authJwtSecret: process.env.AUTH_JWT_SECRET,
|
||||
authTokenExpiresInHours: process.env.AUTH_TOKEN_EXPIRES_IN_HOURS,
|
||||
ntfyServerUrl: process.env.NTFY_SERVER_URL,
|
||||
ntfyTopic: process.env.NTFY_TOPIC,
|
||||
ntfyToken: process.env.NTFY_TOKEN,
|
||||
ntfyPriority: process.env.NTFY_PRIORITY,
|
||||
icalSecret: process.env.ICAL_SECRET
|
||||
};
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
export const config: AppConfig = configSchema.parse(rawConfig);
|
||||
236
src/contractsStore.ts
Normal file
236
src/contractsStore.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import db, { ContractDbRow } from "./db.js";
|
||||
import { Contract, ContractPayload, UpcomingDeadline } from "./types.js";
|
||||
|
||||
const columns = [
|
||||
"title",
|
||||
"paperless_document_id",
|
||||
"provider",
|
||||
"category",
|
||||
"contract_start_date",
|
||||
"contract_end_date",
|
||||
"termination_notice_days",
|
||||
"renewal_period_months",
|
||||
"auto_renew",
|
||||
"price_cents",
|
||||
"currency",
|
||||
"notes",
|
||||
"tags"
|
||||
] as const;
|
||||
|
||||
type ColumnName = (typeof columns)[number];
|
||||
|
||||
type SerializedPayload = {
|
||||
title: string;
|
||||
paperless_document_id: number | null;
|
||||
provider: string | null;
|
||||
category: string | null;
|
||||
contract_start_date: string | null;
|
||||
contract_end_date: string | null;
|
||||
termination_notice_days: number | null;
|
||||
renewal_period_months: number | null;
|
||||
auto_renew: number;
|
||||
price_cents: number | null;
|
||||
currency: string;
|
||||
notes: string | null;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
function toCents(price: number | null | undefined): number | null {
|
||||
if (price === null || price === undefined || Number.isNaN(price)) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(price * 100);
|
||||
}
|
||||
|
||||
function fromCents(priceCents: number | null): number | null {
|
||||
if (priceCents === null || priceCents === undefined) {
|
||||
return null;
|
||||
}
|
||||
return priceCents / 100;
|
||||
}
|
||||
|
||||
function serializeTags(tags: string[] | undefined): string {
|
||||
if (!tags || tags.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
return JSON.stringify(tags);
|
||||
}
|
||||
|
||||
function parseTags(raw: string | null): string[] {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapRow(row: ContractDbRow): Contract {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
paperlessDocumentId: row.paperless_document_id ?? undefined,
|
||||
provider: row.provider ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
contractStartDate: row.contract_start_date ?? undefined,
|
||||
contractEndDate: row.contract_end_date ?? undefined,
|
||||
terminationNoticeDays: row.termination_notice_days ?? undefined,
|
||||
renewalPeriodMonths: row.renewal_period_months ?? undefined,
|
||||
autoRenew: Boolean(row.auto_renew),
|
||||
price: fromCents(row.price_cents),
|
||||
currency: row.currency ?? undefined,
|
||||
notes: row.notes ?? undefined,
|
||||
tags: parseTags(row.tags),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function serializePayload(payload: ContractPayload): SerializedPayload {
|
||||
return {
|
||||
title: payload.title,
|
||||
paperless_document_id: payload.paperlessDocumentId ?? null,
|
||||
provider: payload.provider ?? null,
|
||||
category: payload.category ?? null,
|
||||
contract_start_date: payload.contractStartDate ?? null,
|
||||
contract_end_date: payload.contractEndDate ?? null,
|
||||
termination_notice_days: payload.terminationNoticeDays ?? null,
|
||||
renewal_period_months: payload.renewalPeriodMonths ?? null,
|
||||
auto_renew: payload.autoRenew ? 1 : 0,
|
||||
price_cents: toCents(payload.price),
|
||||
currency: payload.currency ?? "EUR",
|
||||
notes: payload.notes ?? null,
|
||||
tags: serializeTags(payload.tags)
|
||||
};
|
||||
}
|
||||
|
||||
export function listContracts(skip = 0, limit = 100): Contract[] {
|
||||
const stmt = db.prepare<[number, number], ContractDbRow>(
|
||||
`SELECT * FROM contracts ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
);
|
||||
const rows = stmt.all(limit, skip);
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
export function getContract(id: number): Contract | null {
|
||||
const stmt = db.prepare<[number], ContractDbRow>(
|
||||
`SELECT * FROM contracts WHERE id = ?`
|
||||
);
|
||||
const row = stmt.get(id);
|
||||
return row ? mapRow(row) : null;
|
||||
}
|
||||
|
||||
export function createContract(payload: ContractPayload): Contract {
|
||||
const now = new Date().toISOString();
|
||||
const data = serializePayload(payload);
|
||||
const values = columns.map((name) => data[name]);
|
||||
const placeholders = columns.map(() => "?").join(", ");
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO contracts (${columns.join(", ")}, created_at, updated_at)
|
||||
VALUES (${placeholders}, ?, ?)`
|
||||
);
|
||||
|
||||
const result = insert.run(...values, now, now);
|
||||
return getContract(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function updateContract(id: number, payload: Partial<ContractPayload>): Contract | null {
|
||||
const current = getContract(id);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const { id: _, createdAt, updatedAt, ...rest } = current;
|
||||
const merged = { ...rest, ...payload } as ContractPayload;
|
||||
|
||||
merged.title = merged.title || current.title;
|
||||
if (merged.tags === undefined) {
|
||||
merged.tags = current.tags ?? [];
|
||||
}
|
||||
if (merged.currency === undefined) {
|
||||
merged.currency = current.currency ?? "EUR";
|
||||
}
|
||||
if (merged.autoRenew === undefined) {
|
||||
merged.autoRenew = current.autoRenew ?? false;
|
||||
}
|
||||
|
||||
const data = serializePayload(merged);
|
||||
const updates = columns.map((column) => `${column} = ?`).join(", ");
|
||||
const params = columns.map((column) => data[column]);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(
|
||||
`UPDATE contracts SET ${updates}, updated_at = ? WHERE id = ?`
|
||||
);
|
||||
stmt.run(...params, now, id);
|
||||
|
||||
return getContract(id);
|
||||
}
|
||||
|
||||
export function deleteContract(id: number): boolean {
|
||||
const stmt = db.prepare(`DELETE FROM contracts WHERE id = ?`);
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function computeTerminationDeadline(contract: Contract): Date | null {
|
||||
if (!contract.contractEndDate || !contract.terminationNoticeDays) {
|
||||
return null;
|
||||
}
|
||||
const end = new Date(`${contract.contractEndDate}T00:00:00Z`);
|
||||
if (Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const deadline = new Date(end);
|
||||
deadline.setUTCDate(deadline.getUTCDate() - contract.terminationNoticeDays);
|
||||
return deadline;
|
||||
}
|
||||
|
||||
export function listUpcomingDeadlines(withinDays: number): UpcomingDeadline[] {
|
||||
const stmt = db.prepare<[], ContractDbRow>(`SELECT * FROM contracts`);
|
||||
const rows = stmt.all();
|
||||
const items = rows.map(mapRow);
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const horizon = new Date(today);
|
||||
horizon.setUTCDate(horizon.getUTCDate() + withinDays);
|
||||
|
||||
const upcoming: UpcomingDeadline[] = [];
|
||||
|
||||
for (const contract of items) {
|
||||
const deadline = computeTerminationDeadline(contract);
|
||||
if (!deadline) {
|
||||
continue;
|
||||
}
|
||||
if (deadline < today || deadline > horizon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const daysLeft = Math.round(
|
||||
(deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
upcoming.push({
|
||||
id: contract.id,
|
||||
title: contract.title,
|
||||
provider: contract.provider,
|
||||
paperlessDocumentId: contract.paperlessDocumentId,
|
||||
contractEndDate: contract.contractEndDate,
|
||||
terminationDeadline: deadline.toISOString().slice(0, 10),
|
||||
daysUntilDeadline: daysLeft
|
||||
});
|
||||
}
|
||||
|
||||
return upcoming.sort((a, b) => {
|
||||
if (!a.terminationDeadline) return 1;
|
||||
if (!b.terminationDeadline) return -1;
|
||||
return a.terminationDeadline.localeCompare(b.terminationDeadline);
|
||||
});
|
||||
}
|
||||
67
src/db.ts
Normal file
67
src/db.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
const absolutePath = resolve(config.databasePath);
|
||||
const directory = dirname(absolutePath);
|
||||
|
||||
if (!existsSync(directory)) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
logger.info(`Created data directory at ${directory}`);
|
||||
}
|
||||
|
||||
const db = new Database(absolutePath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contracts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
paperless_document_id INTEGER,
|
||||
provider TEXT,
|
||||
category TEXT,
|
||||
contract_start_date TEXT,
|
||||
contract_end_date TEXT,
|
||||
termination_notice_days INTEGER,
|
||||
renewal_period_months INTEGER,
|
||||
auto_renew INTEGER DEFAULT 0,
|
||||
price_cents INTEGER,
|
||||
currency TEXT DEFAULT 'EUR',
|
||||
notes TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export type ContractDbRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
paperless_document_id: number | null;
|
||||
provider: string | null;
|
||||
category: string | null;
|
||||
contract_start_date: string | null;
|
||||
contract_end_date: string | null;
|
||||
termination_notice_days: number | null;
|
||||
renewal_period_months: number | null;
|
||||
auto_renew: number;
|
||||
price_cents: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
tags: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export default db;
|
||||
504
src/index.ts
Normal file
504
src/index.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import {
|
||||
createContract,
|
||||
deleteContract,
|
||||
getContract,
|
||||
listContracts,
|
||||
listUpcomingDeadlines,
|
||||
updateContract
|
||||
} from "./contractsStore.js";
|
||||
import { authenticateRequest, createAccessToken, isAuthEnabled, verifyCredentials } from "./auth.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { paperlessClient } from "./paperlessClient.js";
|
||||
import { deadlineMonitor } from "./scheduler.js";
|
||||
import {
|
||||
ensureIcalSecret,
|
||||
getRuntimeSettings,
|
||||
regenerateIcalSecret,
|
||||
updateRuntimeSettings
|
||||
} from "./runtimeSettings.js";
|
||||
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 { contractCreateSchema, contractUpdateSchema } from "./validators.js";
|
||||
import { formatDateAsICS } from "./utils.js";
|
||||
|
||||
function buildBaseAppUrl(req: Request): string {
|
||||
const forwardedProto = (req.headers["x-forwarded-proto"] as string) ?? req.protocol;
|
||||
const forwardedHost = (req.headers["x-forwarded-host"] as string) ?? req.get("host") ?? "localhost";
|
||||
return `${forwardedProto}://${forwardedHost}`.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
function parseId(param: string): number | null {
|
||||
const value = Number(param);
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function validatePayload<T>(schema: { parse: (data: unknown) => T }, data: unknown) {
|
||||
try {
|
||||
return schema.parse(data);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Validation failed";
|
||||
const validationError = new Error(message);
|
||||
(validationError as Error & { status?: number }).status = 400;
|
||||
throw validationError;
|
||||
}
|
||||
}
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
const settingsUpdateSchema = z.object({
|
||||
paperlessBaseUrl: z.string().url().nullable().optional(),
|
||||
paperlessExternalUrl: z.string().url().nullable().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(),
|
||||
mailServer: z.string().nullable().optional(),
|
||||
mailPort: z.coerce.number().min(1).max(65535).nullable().optional(),
|
||||
mailUsername: z.string().nullable().optional(),
|
||||
mailPassword: z.string().nullable().optional(),
|
||||
mailUseTls: z.boolean().optional(),
|
||||
mailFrom: z.string().email().nullable().optional(),
|
||||
mailTo: z.string().email().nullable().optional(),
|
||||
ntfyServerUrl: z.string().url().nullable().optional(),
|
||||
ntfyTopic: z.string().nullable().optional(),
|
||||
ntfyToken: z.string().nullable().optional(),
|
||||
ntfyPriority: z.string().nullable().optional(),
|
||||
authUsername: z.string().nullable().optional(),
|
||||
authPassword: z.string().nullable().optional(),
|
||||
icalSecret: z.string().min(10).nullable().optional()
|
||||
});
|
||||
|
||||
function formatSettingsResponse(runtime: RuntimeSettings) {
|
||||
return {
|
||||
values: {
|
||||
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
||||
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
||||
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
||||
alertDaysBefore: runtime.alertDaysBefore,
|
||||
mailServer: runtime.mailServer,
|
||||
mailPort: runtime.mailPort,
|
||||
mailUsername: runtime.mailUsername,
|
||||
mailFrom: runtime.mailFrom,
|
||||
mailTo: runtime.mailTo,
|
||||
mailUseTls: runtime.mailUseTls,
|
||||
ntfyServerUrl: runtime.ntfyServerUrl,
|
||||
ntfyTopic: runtime.ntfyTopic,
|
||||
ntfyPriority: runtime.ntfyPriority ?? "default",
|
||||
authUsername: runtime.authUsername
|
||||
},
|
||||
secrets: {
|
||||
paperlessTokenSet: Boolean(runtime.paperlessToken),
|
||||
mailPasswordSet: Boolean(runtime.mailPassword),
|
||||
ntfyTokenSet: Boolean(runtime.ntfyToken),
|
||||
authPasswordSet: Boolean(runtime.authPassword)
|
||||
},
|
||||
icalSecret: runtime.icalSecret
|
||||
};
|
||||
}
|
||||
|
||||
function buildIcsFeed(
|
||||
deadlines: UpcomingDeadline[],
|
||||
paperlessUrl: string | null,
|
||||
appUrl: string | null
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Contracts Companion//DE",
|
||||
"CALSCALE:GREGORIAN"
|
||||
];
|
||||
|
||||
for (const item of deadlines) {
|
||||
if (!item.terminationDeadline) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = formatDateAsICS(item.terminationDeadline);
|
||||
const endDate = new Date(`${item.terminationDeadline}T00:00:00Z`);
|
||||
endDate.setUTCDate(endDate.getUTCDate() + 1);
|
||||
const end = formatDateAsICS(endDate.toISOString().slice(0, 10));
|
||||
|
||||
const summary = `${item.title} – Kündigungsfrist`;
|
||||
const contractUrl = appUrl ? `${appUrl}/contracts/${item.id}` : null;
|
||||
const paperlessLink = item.paperlessDocumentId && paperlessUrl
|
||||
? `${paperlessUrl.replace(/\/$/, "")}/documents/${item.paperlessDocumentId}`
|
||||
: null;
|
||||
|
||||
const descriptionParts = [
|
||||
contractUrl ? `Contract: ${contractUrl}` : null,
|
||||
item.provider ? `Provider: ${item.provider}` : null,
|
||||
item.contractEndDate ? `Contract end: ${item.contractEndDate}` : null,
|
||||
paperlessLink ? `Paperless: ${paperlessLink}` : null
|
||||
].filter(Boolean);
|
||||
|
||||
lines.push("BEGIN:VEVENT");
|
||||
lines.push(`UID:contract-${item.id}@contracts-companion`);
|
||||
lines.push("SUMMARY:" + summary.replace(/\r?\n/g, " "));
|
||||
lines.push("DTSTART;VALUE=DATE:" + start);
|
||||
lines.push("DTEND;VALUE=DATE:" + end);
|
||||
if (descriptionParts.length > 0) {
|
||||
lines.push("DESCRIPTION:" + descriptionParts.join("\\n").replace(/\r?\n/g, "\\n"));
|
||||
}
|
||||
if (contractUrl) {
|
||||
lines.push("URL:" + contractUrl);
|
||||
}
|
||||
lines.push("END:VEVENT");
|
||||
}
|
||||
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
app.get("/healthz", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get("/auth/status", (_req, res) => {
|
||||
res.json({ enabled: isAuthEnabled() });
|
||||
});
|
||||
|
||||
app.post("/auth/login", (req, res) => {
|
||||
if (!isAuthEnabled()) {
|
||||
return res.status(503).json({ error: "Authentication is disabled on the server" });
|
||||
}
|
||||
|
||||
const { username, password } = validatePayload(loginSchema, req.body);
|
||||
|
||||
if (!verifyCredentials(username, password)) {
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createAccessToken(username);
|
||||
res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
app.get("/calendar/feed.ics", (req, res) => {
|
||||
const providedToken = typeof req.query.token === "string" ? req.query.token : null;
|
||||
const secret = ensureIcalSecret();
|
||||
|
||||
if (!providedToken || providedToken !== secret) {
|
||||
return res.status(401).send("Unauthorized");
|
||||
}
|
||||
|
||||
const runtime = { ...getRuntimeSettings(), icalSecret: secret };
|
||||
const deadlines = listUpcomingDeadlines(365);
|
||||
const paperlessUrl = runtime.paperlessExternalUrl ?? runtime.paperlessBaseUrl ?? null;
|
||||
const baseAppUrl = buildBaseAppUrl(req);
|
||||
const ics = buildIcsFeed(deadlines, paperlessUrl, baseAppUrl);
|
||||
|
||||
res.setHeader("Content-Type", "text/calendar; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", "attachment; filename=contracts-deadlines.ics");
|
||||
res.send(ics);
|
||||
});
|
||||
|
||||
app.use(authenticateRequest);
|
||||
|
||||
app.get("/config", (_req, res) => {
|
||||
const runtime = getRuntimeSettings();
|
||||
const paperlessConfigured = Boolean(runtime.paperlessBaseUrl && runtime.paperlessToken);
|
||||
const mailConfigured = Boolean(runtime.mailServer && runtime.mailFrom && runtime.mailTo);
|
||||
const ntfyConfigured = Boolean(runtime.ntfyServerUrl && runtime.ntfyTopic);
|
||||
|
||||
res.json({
|
||||
port: config.port,
|
||||
logLevel: config.logLevel,
|
||||
databasePath: config.databasePath,
|
||||
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
||||
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
||||
paperlessConfigured,
|
||||
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
||||
alertDaysBefore: runtime.alertDaysBefore,
|
||||
mailConfigured,
|
||||
mailServer: runtime.mailServer,
|
||||
mailFrom: runtime.mailFrom,
|
||||
mailUseTls: runtime.mailUseTls,
|
||||
ntfyConfigured,
|
||||
authEnabled: isAuthEnabled(),
|
||||
authTokenExpiresInHours: config.authTokenExpiresInHours
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/settings", (_req, res) => {
|
||||
let runtime = getRuntimeSettings();
|
||||
if (!runtime.icalSecret) {
|
||||
const secret = ensureIcalSecret();
|
||||
runtime = { ...runtime, icalSecret: secret };
|
||||
}
|
||||
res.json(formatSettingsResponse(runtime));
|
||||
});
|
||||
|
||||
app.put("/settings", (req, res) => {
|
||||
const payload = validatePayload(settingsUpdateSchema, req.body);
|
||||
|
||||
const update: Partial<RuntimeSettings> = {};
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessBaseUrl")) {
|
||||
update.paperlessBaseUrl = payload.paperlessBaseUrl ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) {
|
||||
update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessToken")) {
|
||||
update.paperlessToken = payload.paperlessToken ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "schedulerIntervalMinutes")) {
|
||||
update.schedulerIntervalMinutes = payload.schedulerIntervalMinutes;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "alertDaysBefore")) {
|
||||
update.alertDaysBefore = payload.alertDaysBefore;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailServer")) {
|
||||
update.mailServer = payload.mailServer ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailPort")) {
|
||||
update.mailPort = payload.mailPort ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailUsername")) {
|
||||
update.mailUsername = payload.mailUsername ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailPassword")) {
|
||||
update.mailPassword = payload.mailPassword ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailUseTls")) {
|
||||
update.mailUseTls = payload.mailUseTls ?? false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailFrom")) {
|
||||
update.mailFrom = payload.mailFrom ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "mailTo")) {
|
||||
update.mailTo = payload.mailTo ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "ntfyServerUrl")) {
|
||||
update.ntfyServerUrl = payload.ntfyServerUrl ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "ntfyTopic")) {
|
||||
update.ntfyTopic = payload.ntfyTopic ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "ntfyToken")) {
|
||||
update.ntfyToken = payload.ntfyToken ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "ntfyPriority")) {
|
||||
update.ntfyPriority = payload.ntfyPriority ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "authUsername")) {
|
||||
update.authUsername = payload.authUsername ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "authPassword")) {
|
||||
update.authPassword = payload.authPassword ?? null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "icalSecret")) {
|
||||
update.icalSecret = payload.icalSecret ?? null;
|
||||
}
|
||||
|
||||
let runtime = updateRuntimeSettings(update);
|
||||
if (!runtime.icalSecret) {
|
||||
const secret = ensureIcalSecret();
|
||||
runtime = { ...runtime, icalSecret: secret };
|
||||
}
|
||||
deadlineMonitor.stop();
|
||||
deadlineMonitor.start();
|
||||
res.json(formatSettingsResponse(runtime));
|
||||
});
|
||||
|
||||
app.post("/settings/ical-secret/reset", (_req, res) => {
|
||||
const secret = regenerateIcalSecret();
|
||||
const runtime = { ...getRuntimeSettings(), icalSecret: secret };
|
||||
res.json({ icalSecret: runtime.icalSecret });
|
||||
});
|
||||
|
||||
app.post("/settings/test/mail", async (_req, res, next) => {
|
||||
try {
|
||||
const runtime = getRuntimeSettings();
|
||||
if (!runtime.mailServer || !runtime.mailFrom || !runtime.mailTo) {
|
||||
return res.status(400).json({ error: "E-Mail-Konfiguration unvollständig" });
|
||||
}
|
||||
await sendTestEmail(runtime);
|
||||
res.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/settings/test/ntfy", async (_req, res, next) => {
|
||||
try {
|
||||
const runtime = getRuntimeSettings();
|
||||
if (!runtime.ntfyServerUrl || !runtime.ntfyTopic) {
|
||||
return res.status(400).json({ error: "ntfy-Konfiguration unvollständig" });
|
||||
}
|
||||
await sendTestNtfy(runtime);
|
||||
res.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/integrations/paperless/search", async (req, res, next) => {
|
||||
const query = (typeof req.query.q === "string" ? req.query.q : typeof req.query.query === "string" ? req.query.query : "").trim();
|
||||
const page = Number(req.query.page ?? 1);
|
||||
|
||||
if (!paperlessClient.isConfigured) {
|
||||
return res.status(503).json({ error: "Paperless integration not configured" });
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: "Query parameter 'q' is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await paperlessClient.searchDocuments(query, Number.isFinite(page) && page > 0 ? page : 1);
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/contracts", (req, res) => {
|
||||
const skip = Number(req.query.skip ?? 0);
|
||||
const limit = Math.min(Number(req.query.limit ?? 100), 500);
|
||||
|
||||
if (Number.isNaN(skip) || skip < 0 || Number.isNaN(limit) || limit <= 0) {
|
||||
return res.status(400).json({ error: "Invalid pagination parameters" });
|
||||
}
|
||||
|
||||
const contracts = listContracts(skip, limit);
|
||||
res.json(contracts);
|
||||
});
|
||||
|
||||
app.post("/contracts", (req, res) => {
|
||||
const payload = validatePayload(contractCreateSchema, req.body) as ContractPayload;
|
||||
const contract = createContract(payload);
|
||||
res.status(201).json(contract);
|
||||
});
|
||||
|
||||
app.get("/contracts/:id", (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Invalid contract id" });
|
||||
}
|
||||
const contract = getContract(id);
|
||||
if (!contract) {
|
||||
return res.status(404).json({ error: "Contract not found" });
|
||||
}
|
||||
res.json(contract);
|
||||
});
|
||||
|
||||
app.put("/contracts/:id", (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Invalid contract id" });
|
||||
}
|
||||
|
||||
const payload = validatePayload(contractUpdateSchema, req.body) as Partial<ContractPayload>;
|
||||
const contract = updateContract(id, payload);
|
||||
if (!contract) {
|
||||
return res.status(404).json({ error: "Contract not found" });
|
||||
}
|
||||
res.json(contract);
|
||||
});
|
||||
|
||||
app.delete("/contracts/:id", (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Invalid contract id" });
|
||||
}
|
||||
const deleted = deleteContract(id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Contract not found" });
|
||||
}
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
app.get("/contracts/:id/paperless-document", async (req, res, next) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Invalid contract id" });
|
||||
}
|
||||
|
||||
const contract = getContract(id);
|
||||
if (!contract) {
|
||||
return res.status(404).json({ error: "Contract not found" });
|
||||
}
|
||||
|
||||
if (!contract.paperlessDocumentId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Contract is not linked to a paperless document" });
|
||||
}
|
||||
|
||||
if (!paperlessClient.isConfigured) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "Paperless integration not configured" });
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await paperlessClient.getDocument(contract.paperlessDocumentId);
|
||||
if (!doc) {
|
||||
return res.status(404).json({ error: "Document not found in paperless" });
|
||||
}
|
||||
res.json(doc);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/reports/upcoming", (req, res) => {
|
||||
const daysParam = req.query.days;
|
||||
let horizon = config.alertDaysBefore;
|
||||
if (daysParam !== undefined) {
|
||||
const parsed = Number(daysParam);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
return res.status(400).json({ error: "Invalid days parameter" });
|
||||
}
|
||||
horizon = parsed;
|
||||
}
|
||||
const deadlines = listUpcomingDeadlines(horizon);
|
||||
res.json(deadlines);
|
||||
});
|
||||
|
||||
app.use(
|
||||
(error: Error & { status?: number }, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = error.status ?? 500;
|
||||
if (status >= 500) {
|
||||
logger.error("Unhandled error", error);
|
||||
} else {
|
||||
logger.warn("Request failed", error.message);
|
||||
}
|
||||
res.status(status).json({ error: error.message ?? "Internal error" });
|
||||
}
|
||||
);
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
logger.info(`Contract companion service listening on port ${config.port}`);
|
||||
if (isAuthEnabled()) {
|
||||
logger.info("Authentication enabled for API endpoints.");
|
||||
} else {
|
||||
logger.warn("Authentication is disabled; consider setting AUTH_USERNAME and AUTH_PASSWORD.");
|
||||
}
|
||||
deadlineMonitor.start();
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("Received SIGTERM, shutting down.");
|
||||
deadlineMonitor.stop();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Received SIGINT, shutting down.");
|
||||
deadlineMonitor.stop();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
44
src/logger.ts
Normal file
44
src/logger.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const levelWeights: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor(private readonly level: LogLevel) {}
|
||||
|
||||
private shouldLog(target: LogLevel) {
|
||||
return levelWeights[target] >= levelWeights[this.level];
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]) {
|
||||
if (this.shouldLog("debug")) {
|
||||
console.debug(`[DEBUG] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]) {
|
||||
if (this.shouldLog("info")) {
|
||||
console.info(`[INFO] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
if (this.shouldLog("warn")) {
|
||||
console.warn(`[WARN] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]) {
|
||||
if (this.shouldLog("error")) {
|
||||
console.error(`[ERROR] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(level: LogLevel) {
|
||||
return new Logger(level);
|
||||
}
|
||||
112
src/notifications.ts
Normal file
112
src/notifications.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
const useImplicitTls = settings.mailUseTls && settings.mailPort === 465;
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: settings.mailServer,
|
||||
port: settings.mailPort ?? 587,
|
||||
secure: useImplicitTls,
|
||||
requireTLS: settings.mailUseTls && !useImplicitTls,
|
||||
auth:
|
||||
settings.mailUsername && settings.mailPassword
|
||||
? {
|
||||
user: settings.mailUsername,
|
||||
pass: settings.mailPassword
|
||||
}
|
||||
: undefined,
|
||||
tls: settings.mailUseTls ? { minVersion: "TLSv1.2" } : undefined
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: settings.mailFrom,
|
||||
to: settings.mailTo,
|
||||
subject,
|
||||
text: body
|
||||
});
|
||||
}
|
||||
|
||||
async function sendNtfy(subject: string, body: string, settings: RuntimeSettings): Promise<void> {
|
||||
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
||||
logger.debug("ntfy configuration missing; skipping push notification.");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`;
|
||||
const headers: Record<string, string> = {
|
||||
Title: subject
|
||||
};
|
||||
if (settings.ntfyToken) {
|
||||
headers.Authorization = `Bearer ${settings.ntfyToken}`;
|
||||
}
|
||||
if (settings.ntfyPriority) {
|
||||
headers.Priority = settings.ntfyPriority;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`ntfy responded with ${response.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDeadlineNotifications(subject: string, lines: string[], settings: RuntimeSettings) {
|
||||
const message = lines.join("\n");
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
||||
if (settings.mailServer && settings.mailFrom && settings.mailTo) {
|
||||
tasks.push(
|
||||
sendEmail(subject, message, settings).then(() => {
|
||||
logger.info("Deadline alert email sent.");
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.ntfyServerUrl && settings.ntfyTopic) {
|
||||
tasks.push(
|
||||
sendNtfy(subject, message, settings).then(() => {
|
||||
logger.info("Deadline alert pushed via ntfy.");
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
logger.debug("No notification channels configured; skipping alerts.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(tasks);
|
||||
} catch (error) {
|
||||
logger.error("Failed to deliver one or more notifications", error);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
76
src/paperlessClient.ts
Normal file
76
src/paperlessClient.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { config } from "./config.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
export class PaperlessClient {
|
||||
get isConfigured() {
|
||||
const { paperlessBaseUrl, paperlessToken } = getRuntimeSettings();
|
||||
return Boolean(paperlessBaseUrl && paperlessToken);
|
||||
}
|
||||
|
||||
private buildUrl(path: string) {
|
||||
const { paperlessBaseUrl } = getRuntimeSettings();
|
||||
if (!paperlessBaseUrl) {
|
||||
throw new Error("Paperless base URL is not configured");
|
||||
}
|
||||
const trimmedBase = paperlessBaseUrl.replace(/\/+$/, "");
|
||||
const trimmedPath = path.replace(/^\/+/, "");
|
||||
return `${trimmedBase}/${trimmedPath}`;
|
||||
}
|
||||
|
||||
private getHeaders(): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json"
|
||||
};
|
||||
const { paperlessToken } = getRuntimeSettings();
|
||||
if (paperlessToken) {
|
||||
headers.Authorization = `Token ${paperlessToken}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async getDocument(documentId: number): Promise<Record<string, unknown> | null> {
|
||||
if (!this.isConfigured) {
|
||||
throw new Error("Paperless integration is not configured");
|
||||
}
|
||||
|
||||
const url = this.buildUrl(`/api/documents/${documentId}/`);
|
||||
const response = await fetch(url, { headers: this.getHeaders() });
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.error(`Paperless API error ${response.status}: ${text}`);
|
||||
throw new Error(`Paperless request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async searchDocuments(query: string, page = 1): Promise<Record<string, unknown>> {
|
||||
if (!this.isConfigured) {
|
||||
throw new Error("Paperless integration is not configured");
|
||||
}
|
||||
|
||||
const url = new URL(this.buildUrl("/api/documents/"));
|
||||
url.searchParams.set("query", query);
|
||||
url.searchParams.set("page", page.toString());
|
||||
|
||||
const response = await fetch(url, { headers: this.getHeaders() });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.error(`Paperless API error ${response.status}: ${text}`);
|
||||
throw new Error(`Paperless request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
export const paperlessClient = new PaperlessClient();
|
||||
140
src/runtimeSettings.ts
Normal file
140
src/runtimeSettings.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { config } from "./config.js";
|
||||
import {
|
||||
ensureSetting,
|
||||
generateSecret,
|
||||
listSettings,
|
||||
removeSetting,
|
||||
setSetting,
|
||||
SettingKey,
|
||||
StoredSettings
|
||||
} from "./settingsStore.js";
|
||||
|
||||
export interface RuntimeSettings {
|
||||
paperlessBaseUrl: string | null;
|
||||
paperlessExternalUrl: string | null;
|
||||
paperlessToken: string | null;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
mailServer: string | null;
|
||||
mailPort: number | null;
|
||||
mailUsername: string | null;
|
||||
mailPassword: string | null;
|
||||
mailUseTls: boolean;
|
||||
mailFrom: string | null;
|
||||
mailTo: string | null;
|
||||
ntfyServerUrl: string | null;
|
||||
ntfyTopic: string | null;
|
||||
ntfyToken: string | null;
|
||||
ntfyPriority: string | null;
|
||||
authUsername: string | null;
|
||||
authPassword: string | null;
|
||||
icalSecret: string | null;
|
||||
}
|
||||
|
||||
const numericKeys = new Set<SettingKey>(["schedulerIntervalMinutes", "alertDaysBefore", "mailPort"]);
|
||||
const booleanKeys = new Set<SettingKey>(["mailUseTls"]);
|
||||
|
||||
function coerceNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function coerceBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function coerceString(value: unknown, fallback: string | null): string | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getRuntimeSettings(): RuntimeSettings {
|
||||
const stored = listSettings();
|
||||
|
||||
const schedulerIntervalMinutes = coerceNumber(
|
||||
stored.schedulerIntervalMinutes,
|
||||
config.schedulerIntervalMinutes
|
||||
);
|
||||
const alertDaysBefore = coerceNumber(stored.alertDaysBefore, config.alertDaysBefore);
|
||||
const mailPort = stored.mailPort !== undefined ? coerceNumber(stored.mailPort, config.mailPort) : config.mailPort;
|
||||
|
||||
return {
|
||||
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
|
||||
paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null),
|
||||
paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null),
|
||||
schedulerIntervalMinutes,
|
||||
alertDaysBefore,
|
||||
mailServer: coerceString(stored.mailServer, config.mailServer ?? null),
|
||||
mailPort,
|
||||
mailUsername: coerceString(stored.mailUsername, config.mailUsername ?? null),
|
||||
mailPassword: coerceString(stored.mailPassword, config.mailPassword ?? null),
|
||||
mailUseTls:
|
||||
stored.mailUseTls !== undefined
|
||||
? coerceBoolean(stored.mailUseTls, config.mailUseTls)
|
||||
: config.mailUseTls,
|
||||
mailFrom: coerceString(stored.mailFrom, config.mailFrom ?? null),
|
||||
mailTo: coerceString(stored.mailTo, config.mailTo ?? null),
|
||||
ntfyServerUrl: coerceString(stored.ntfyServerUrl, config.ntfyServerUrl ?? null),
|
||||
ntfyTopic: coerceString(stored.ntfyTopic, config.ntfyTopic ?? null),
|
||||
ntfyToken: coerceString(stored.ntfyToken, config.ntfyToken ?? null),
|
||||
ntfyPriority: coerceString(stored.ntfyPriority, config.ntfyPriority ?? null),
|
||||
authUsername: coerceString(stored.authUsername, config.authUsername ?? null),
|
||||
authPassword: coerceString(stored.authPassword, config.authPassword ?? null),
|
||||
icalSecret: coerceString(stored.icalSecret, config.icalSecret ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
removeSetting(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (numericKeys.has(key)) {
|
||||
const numericValue = coerceNumber(value, 0);
|
||||
setSetting(key, numericValue);
|
||||
} else if (booleanKeys.has(key)) {
|
||||
const boolValue = coerceBoolean(value, false);
|
||||
setSetting(key, boolValue);
|
||||
} else {
|
||||
setSetting(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return getRuntimeSettings();
|
||||
}
|
||||
|
||||
export function ensureIcalSecret(): string {
|
||||
const value = ensureSetting("icalSecret", () => generateSecret(24));
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function regenerateIcalSecret(): string {
|
||||
const secret = generateSecret(24);
|
||||
setSetting("icalSecret", secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function getStoredSettings(): StoredSettings {
|
||||
return listSettings();
|
||||
}
|
||||
85
src/scheduler.ts
Normal file
85
src/scheduler.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { config } from "./config.js";
|
||||
import { listUpcomingDeadlines } from "./contractsStore.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { sendDeadlineNotifications } from "./notifications.js";
|
||||
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||
|
||||
const logger = createLogger(config.logLevel);
|
||||
|
||||
export class DeadlineMonitor {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
|
||||
start() {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
const settings = getRuntimeSettings();
|
||||
logger.info(
|
||||
`Starting deadline monitor (interval=${settings.schedulerIntervalMinutes} min, alert window=${settings.alertDaysBefore} days)`
|
||||
);
|
||||
this.scheduleNext(1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
logger.info("Deadline monitor stopped.");
|
||||
}
|
||||
|
||||
private scheduleNext(delayMs?: number) {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
const settings = getRuntimeSettings();
|
||||
const intervalMs = Math.max(settings.schedulerIntervalMinutes, 5) * 60 * 1000;
|
||||
const wait = delayMs ?? intervalMs;
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.runCheck()
|
||||
.catch((error) => {
|
||||
logger.error("Deadline check failed", error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.scheduleNext();
|
||||
});
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private async runCheck() {
|
||||
const settings = getRuntimeSettings();
|
||||
const deadlines = listUpcomingDeadlines(settings.alertDaysBefore);
|
||||
|
||||
if (deadlines.length === 0) {
|
||||
logger.debug("No upcoming deadlines within alert window.");
|
||||
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)",
|
||||
item.title,
|
||||
item.provider ?? "n/a",
|
||||
item.paperlessDocumentId ?? "n/a",
|
||||
item.terminationDeadline ?? "n/a",
|
||||
item.daysUntilDeadline ?? "n/a"
|
||||
);
|
||||
}
|
||||
|
||||
await sendDeadlineNotifications("Contract termination reminder", lines, settings);
|
||||
}
|
||||
}
|
||||
|
||||
export const deadlineMonitor = new DeadlineMonitor();
|
||||
83
src/settingsStore.ts
Normal file
83
src/settingsStore.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import db from "./db.js";
|
||||
|
||||
const getStmt = db.prepare("SELECT value FROM settings WHERE key = ?");
|
||||
const upsertStmt = db.prepare("INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at");
|
||||
const deleteStmt = db.prepare("DELETE FROM settings WHERE key = ?");
|
||||
const listStmt = db.prepare("SELECT key, value FROM settings");
|
||||
|
||||
export type SettingKey =
|
||||
| "paperlessBaseUrl"
|
||||
| "paperlessExternalUrl"
|
||||
| "paperlessToken"
|
||||
| "schedulerIntervalMinutes"
|
||||
| "alertDaysBefore"
|
||||
| "mailServer"
|
||||
| "mailPort"
|
||||
| "mailUsername"
|
||||
| "mailPassword"
|
||||
| "mailUseTls"
|
||||
| "mailFrom"
|
||||
| "mailTo"
|
||||
| "ntfyServerUrl"
|
||||
| "ntfyTopic"
|
||||
| "ntfyToken"
|
||||
| "ntfyPriority"
|
||||
| "authUsername"
|
||||
| "authPassword"
|
||||
| "icalSecret";
|
||||
|
||||
export type StoredSettings = Partial<Record<SettingKey, unknown>>;
|
||||
|
||||
export function getSetting<T = unknown>(key: SettingKey): T | undefined {
|
||||
const row = getStmt.get(key) as { value: string } | undefined;
|
||||
if (!row || typeof row.value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSetting(key: SettingKey, value: unknown): void {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
deleteStmt.run(key);
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
upsertStmt.run(key, JSON.stringify(value), now);
|
||||
}
|
||||
|
||||
export function removeSetting(key: SettingKey): void {
|
||||
deleteStmt.run(key);
|
||||
}
|
||||
|
||||
export function listSettings(): StoredSettings {
|
||||
const rows = listStmt.all() as Array<{ key: string; value: string }>;
|
||||
const result: StoredSettings = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
result[row.key as SettingKey] = JSON.parse(row.value);
|
||||
} catch (_error) {
|
||||
// ignore bad data
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ensureSetting(key: SettingKey, generator: () => unknown): unknown {
|
||||
const existing = getSetting(key);
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
const value = generator();
|
||||
setSetting(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function generateSecret(length = 32): string {
|
||||
return crypto.randomBytes(length).toString("hex");
|
||||
}
|
||||
31
src/types.ts
Normal file
31
src/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface ContractPayload {
|
||||
title: string;
|
||||
paperlessDocumentId?: number | null;
|
||||
provider?: string | null;
|
||||
category?: string | null;
|
||||
contractStartDate?: string | null;
|
||||
contractEndDate?: string | null;
|
||||
terminationNoticeDays?: number | null;
|
||||
renewalPeriodMonths?: number | null;
|
||||
autoRenew?: boolean;
|
||||
price?: number | null;
|
||||
currency?: string;
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface Contract extends ContractPayload {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpcomingDeadline {
|
||||
id: number;
|
||||
title: string;
|
||||
provider?: string | null;
|
||||
paperlessDocumentId?: number | null;
|
||||
contractEndDate?: string | null;
|
||||
terminationDeadline?: string | null;
|
||||
daysUntilDeadline?: number | null;
|
||||
}
|
||||
7
src/utils.ts
Normal file
7
src/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function formatDateAsICS(dateString: string): string {
|
||||
const date = new Date(`${dateString}T00:00:00Z`);
|
||||
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
||||
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getUTCDate().toString().padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
26
src/validators.ts
Normal file
26
src/validators.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const nullableString = z.union([z.string().trim().min(1).max(255), z.null()]).optional();
|
||||
const nullableDate = z.union([z.string().regex(dateRegex, "Expected YYYY-MM-DD format"), z.null()]).optional();
|
||||
const nullableInteger = z.union([z.number().int().nonnegative(), z.null()]).optional();
|
||||
const nullableNumber = z.union([z.number().nonnegative(), z.null()]).optional();
|
||||
|
||||
export const contractCreateSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
paperlessDocumentId: nullableInteger,
|
||||
provider: nullableString,
|
||||
category: nullableString,
|
||||
contractStartDate: nullableDate,
|
||||
contractEndDate: nullableDate,
|
||||
terminationNoticeDays: nullableInteger,
|
||||
renewalPeriodMonths: nullableInteger,
|
||||
autoRenew: z.boolean().optional().default(false),
|
||||
price: nullableNumber,
|
||||
currency: z.string().min(1).max(8).optional().default("EUR"),
|
||||
notes: z.union([z.string().max(5000), z.null()]).optional(),
|
||||
tags: z.array(z.string().min(1).max(100)).optional().default([])
|
||||
});
|
||||
|
||||
export const contractUpdateSchema = contractCreateSchema.partial();
|
||||
Reference in New Issue
Block a user