Files
Paperless-Contracts/src/index.ts
2025-10-11 11:32:04 +02:00

526 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("/integrations/paperless/documents/:documentId", async (req, res, next) => {
if (!paperlessClient.isConfigured) {
return res.status(503).json({ error: "Paperless integration not configured" });
}
const documentId = parseId(req.params.documentId);
if (!documentId) {
return res.status(400).json({ error: "Invalid document id" });
}
try {
const document = await paperlessClient.getDocument(documentId);
if (!document) {
return res.status(404).json({ error: "Document not found" });
}
res.json(document);
} 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));
});