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(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 = {}; 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; 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)); });