526 lines
17 KiB
TypeScript
526 lines
17 KiB
TypeScript
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));
|
||
});
|