import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';

// .env-Dateien laden – Root-.env hat Priorität (zentrale Konfiguration für
// Dev + Docker), backend/.env als Legacy-Fallback. Im Container sind
// Variablen schon via env_file/environment gesetzt – dotenv überschreibt
// existierende process.env-Werte nicht.
// __dirname zeigt auf src/ (dev via tsx) oder dist/ (build). In beiden Fällen
// liegt Root /.env zwei Ebenen darüber.
//
// dotenvExpand löst ${VAR}-Substitution auf, sodass z.B.
// DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
// dynamisch aus den Komponenten zusammengebaut wird (kein Doppel-Pflegen).
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../../.env') }));
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../.env') }));
dotenvExpand.expand(dotenv.config());

// Fallback: wenn DATABASE_URL nicht direkt gesetzt ist (oder Substitution
// nicht funktioniert hat), aus den DB_*-Komponenten zusammenbauen.
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
  const u = encodeURIComponent(process.env.DB_USER);
  const p = encodeURIComponent(process.env.DB_PASSWORD);
  const h = process.env.DB_HOST || 'localhost';
  const port = process.env.DB_PORT || '3306';
  process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
}

import { apiBackstopRateLimiter } from './middleware/rateLimit.js';
import authRoutes from './routes/auth.routes.js';
import customerRoutes from './routes/customer.routes.js';
import addressRoutes from './routes/address.routes.js';
import bankcardRoutes from './routes/bankcard.routes.js';
import documentRoutes from './routes/document.routes.js';
import meterRoutes from './routes/meter.routes.js';
import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js';
import contractRoutes from './routes/contract.routes.js';
import creditNoteRoutes from './routes/creditNote.routes.js';
import companyProfileRoutes from './routes/companyProfile.routes.js';
import platformRoutes from './routes/platform.routes.js';
import cancellationPeriodRoutes from './routes/cancellation-period.routes.js';
import contractDurationRoutes from './routes/contract-duration.routes.js';
import providerRoutes from './routes/provider.routes.js';
import tariffRoutes from './routes/tariff.routes.js';
import userRoutes from './routes/user.routes.js';
import uploadRoutes from './routes/upload.routes.js';
import developerRoutes from './routes/developer.routes.js';
import contractCategoryRoutes from './routes/contractCategory.routes.js';
import contractTaskRoutes from './routes/contractTask.routes.js';
import appSettingRoutes from './routes/appSetting.routes.js';
import emailProviderRoutes from './routes/emailProvider.routes.js';
import cachedEmailRoutes from './routes/cachedEmail.routes.js';
import invoiceRoutes from './routes/invoice.routes.js';
import contractHistoryRoutes from './routes/contractHistory.routes.js';
import auditLogRoutes from './routes/auditLog.routes.js';
import gdprRoutes from './routes/gdpr.routes.js';
import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js';
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { downloadFile } from './controllers/fileDownload.controller.js';
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
import { authenticate } from './middleware/auth.js';

// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
  console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
  console.error('   Generiere mit: openssl rand -hex 64');
  process.exit(1);
}
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
  console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)');
  console.error('   Generiere mit: openssl rand -hex 32');
  process.exit(1);
}

const app = express();
const PORT = process.env.PORT || 3001;

// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
//
// Zwei Szenarien:
// 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf
//    EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut
//    genau einem Hop → req.ip = echter Client (nicht der Proxy).
//    Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar,
//    sonst könnte ein Direkt-Connect X-Forwarded-For faken und den
//    Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das
//    durch das Docker-Network + nicht-veröffentlichten Backend-Port
//    gewährleistet.
// 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port-
//    Zugriff): `loopback` reicht – kein vertrauenswürdiger Hop davor.
//
// Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup
// IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und
// IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client.
const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback';
app.set('trust proxy', trustProxyValue);

// ==================== SECURITY MIDDLEWARE ====================

// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, CSP, ...)
//
// CSP ist konservativ aber SPA-tauglich:
//   - script-src 'self'        → keine externen Skripte, keine inline-Scripts
//                                 (Vite baut Module-Skripte zu separaten Files,
//                                 die sind 'self')
//   - style-src 'self' 'unsafe-inline' → Tailwind/inline-Styles brauchen das
//                                        (sicheres Trade-off; XSS via CSS ist
//                                        marginal vs Lock-Out gegen die UI)
//   - img-src self/data/blob   → base64-Avatare + blob-URLs für PDFs/Downloads
//   - font-src self/data       → eingebettete Fonts
//   - connect-src 'self'       → API + WebSocket nur zur eigenen Origin
//   - frame-ancestors 'none'   → Clickjacking-Schutz (ersetzt X-Frame-Options)
//   - object-src 'none'        → keine Flash/<object>/<embed>-Embeds
//   - base-uri 'self'          → keine <base>-Hijacking-Tricks
//   - form-action 'self'       → POST-Targets nur auf eigene Origin
// Permissions-Policy: schaltet Browser-APIs aus, die wir nicht brauchen.
// Verhindert, dass eingeschleustes JS Zugriff auf Kamera/Mikro/GPS/Payment etc.
// bekommt. clipboard-write ist 'self' für die CopyButton-Komponenten,
// fullscreen 'self' falls jemand mal eine Vorschau in Vollbild öffnet.
app.use((_req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    [
      'accelerometer=()',
      'ambient-light-sensor=()',
      'autoplay=()',
      'battery=()',
      'camera=()',
      'clipboard-read=()',
      'clipboard-write=(self)',
      'cross-origin-isolated=()',
      'display-capture=()',
      'encrypted-media=()',
      'fullscreen=(self)',
      'geolocation=()',
      'gyroscope=()',
      'hid=()',
      'idle-detection=()',
      'magnetometer=()',
      'microphone=()',
      'midi=()',
      'payment=()',
      'picture-in-picture=()',
      'publickey-credentials-get=()',
      'screen-wake-lock=()',
      'sync-xhr=()',
      'usb=()',
      'web-share=()',
      'xr-spatial-tracking=()',
    ].join(', '),
  );
  next();
});

// HTTPS-only-Header (HSTS + upgrade-insecure-requests) nur setzen, wenn
// wirklich TLS davor läuft – sonst sperrt sich die App auf direkt-via-IP-
// Deployments (Browser versucht /assets/* via https zu laden → SSL-Error).
// Aktivieren mit HTTPS_ENABLED=true in der .env, sobald ein TLS-Proxy
// (Caddy/Traefik/Nginx) vor OpenCRM steht.
const httpsEnabled = process.env.HTTPS_ENABLED === 'true';

app.use(
  helmet({
    contentSecurityPolicy: {
      useDefaults: true,
      directives: {
        'default-src': ["'self'"],
        'script-src': ["'self'"],
        'style-src': ["'self'", "'unsafe-inline'"],
        'img-src': ["'self'", 'data:', 'blob:'],
        'font-src': ["'self'", 'data:'],
        'connect-src': ["'self'"],
        // Explizit gesetzt obwohl Fallback auf default-src/script-src greift –
        // ZAP markiert sonst "No-Fallback-Direktiven" als CSP-Lücke.
        'worker-src': ["'self'"],
        'manifest-src': ["'self'"],
        'media-src': ["'self'"],
        // 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
        // annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
        // 'none' würde sogar same-origin blocken und damit die UI brechen.
        // Externe Sites bleiben weiterhin gesperrt.
        'frame-ancestors': ["'self'"],
        'object-src': ["'none'"],
        'base-uri': ["'self'"],
        'form-action': ["'self'"],
        // useDefaults bringt 'upgrade-insecure-requests' selbst mit – explizit
        // auf null setzen entfernt es aus dem Header (helmet-API).
        'upgrade-insecure-requests': httpsEnabled ? [] : null,
      },
    },
    // HSTS NIE in Helmet senden – der vorgelagerte TLS-Reverse-Proxy
    // (Nginx Proxy Manager) macht das bereits. Doppelter Header verletzt
    // RFC 6797 (Multiple Header Entries) und wird von ZAP angemahnt.
    // HTTPS_ENABLED-Flag bleibt für upgrade-insecure-requests (CSP) relevant.
    strictTransportSecurity: false,
    crossOriginResourcePolicy: { policy: 'same-site' },
  }),
);

// CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben.
const corsOrigins = process.env.CORS_ORIGINS
  ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim())
  : process.env.NODE_ENV === 'production'
    ? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert)
    : true; // Dev: alles erlauben

app.use(
  cors({
    origin: corsOrigins,
    credentials: true,
  }),
);

// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
app.use(express.json({ limit: '5mb' }));
// Cookie-Parser: wird für den httpOnly-Refresh-Token-Cookie gebraucht
// (POST /api/auth/refresh liest ihn aus req.cookies).
app.use(cookieParser());

// Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware);
app.use(auditMiddleware);

// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
// `/api/uploads/*` express.static).
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
// und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
//
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
// Request über denselben Owner-Check (kein freier static-Handler mehr).

// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> – egal ob als
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
app.get('/api/files/download', authenticate as any, downloadFile as any);
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
  // Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
  req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
  return (downloadFile as any)(req, res, next);
});

// Pentest 55.3 (HIGH, 2026-06-01): /uploads/contract-documents/*.pdf
// kam ungeschützt durch, weil der nginx-Reverse-Proxy die Dateien
// direkt aus dem Filesystem auslieferte und der Backend-Auth-Check
// nur bei /api/uploads/* griff. Defense-in-Depth: dieselbe Route auch
// ohne /api-Präfix freischalten – damit der Backend-Owner-Check immer
// läuft, egal wie nginx konfiguriert ist.
app.get('/uploads/*', authenticate as any, (req, res, next) => {
  req.query.path = req.originalUrl.split('?')[0];
  return (downloadFile as any)(req, res, next);
});

// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
// (siehe express.static mit immutable weiter unten).
app.use('/api', (_req, res, next) => {
  res.setHeader('Cache-Control', 'no-store');
  next();
});

// Globaler Sanitizer für Fehler-Antworten: bekannte ORM-/Stack-Trace-Muster
// in `error`/`details`-Strings ersetzen, bevor sie an den Client gehen.
// So leakten frühere Builds bei z.B. `PUT /api/users/99999` rohe
// Prisma-Internals wie "Invalid `prisma.user.update()` invocation:
// Record to update not found" (Pentest Runde 11 M3). Der Original-Text
// landet weiterhin im Server-Log.
const ORM_LEAK_PATTERNS: RegExp[] = [
  /Invalid `prisma\./i,
  /PrismaClient/i,
  /^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame
  /at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col
  // JS-Runtime-Fehler – Pentest Runde 12 (2026-05-18): "Cannot read
  // properties of undefined (reading 'substring')" leakte aus POST
  // /contracts. Solche Texte verraten Implementierungs-Details.
  /^TypeError\b/i,
  /^ReferenceError\b/i,
  /^SyntaxError\b/i,
  /^RangeError\b/i,
  /Cannot read propert(y|ies) of (undefined|null)/i,
  /is not a function/i,
  /is not defined$/i,
];
function sanitizeErrorString(s: string): string {
  if (!s) return s;
  for (const re of ORM_LEAK_PATTERNS) {
    if (re.test(s)) {
      console.error('[orm-leak-guard] Maskierte Fehlermeldung:', s.slice(0, 300));
      return 'Operation fehlgeschlagen';
    }
  }
  return s;
}
app.use('/api', (_req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = (body: any) => {
    if (body && typeof body === 'object') {
      if (typeof body.error === 'string') {
        body.error = sanitizeErrorString(body.error);
      }
      if (typeof body.details === 'string') {
        body.details = sanitizeErrorString(body.details);
      }
    }
    return originalJson(body);
  };
  next();
});

// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
//
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
// nicht geblockt.
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
app.use('/api', (req, res, next) => {
  for (const seg of req.path.split('/')) {
    if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
      res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
      return;
    }
  }
  next();
});

// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
app.use('/api', apiBackstopRateLimiter);

// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/customers', customerRoutes);
app.use('/api/addresses', addressRoutes);
app.use('/api/bank-cards', bankcardRoutes);
app.use('/api/documents', documentRoutes);
app.use('/api/meters', meterRoutes);
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
app.use('/api/contracts', contractRoutes);
app.use('/api/credit-notes', creditNoteRoutes);
app.use('/api/company-profile', companyProfileRoutes);
app.use('/api/platforms', platformRoutes);
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
app.use('/api/contract-durations', contractDurationRoutes);
app.use('/api/providers', providerRoutes);
app.use('/api/tariffs', tariffRoutes);
app.use('/api/users', userRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/developer', developerRoutes);
app.use('/api/contract-categories', contractCategoryRoutes);
app.use('/api', contractTaskRoutes);
app.use('/api/settings', appSettingRoutes);
app.use('/api/email-providers', emailProviderRoutes);
app.use('/api', cachedEmailRoutes);
app.use('/api/energy-details', invoiceRoutes);
app.use('/api', contractHistoryRoutes);
app.use('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);

// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
// "ok" + Timestamp, keine Version, kein DB-Status, kein Hostname – damit
// auch unauth Caller keine internen Infos einsammeln können. Pentest
// 2026-05-20 (INFO): kein Auth → akzeptiert, Antwort liefert nichts
// Sensibles.
app.get('/api/health', (_req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Production: Serve frontend static files
if (process.env.NODE_ENV === 'production') {
  const publicPath = path.join(process.cwd(), 'public');

  // Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash
  // im Dateinamen – das Image ist also versioniert. Daher kann der Browser
  // sie für ein Jahr aggressiv cachen und muss nicht revalidieren.
  app.use(
    '/assets',
    express.static(path.join(publicPath, 'assets'), {
      maxAge: '1y',
      immutable: true,
    }),
  );

  // Rest des Frontends (index.html selbst, vite.svg, robots.txt, sitemap.xml).
  // express.static findet index.html bei GET /, deshalb MUSS hier das gleiche
  // no-store-Verhalten greifen wie im SPA-Fallback weiter unten – sonst
  // serviert der erste Static-Handler / mit dem express-Default `max-age=0`,
  // bevor der Fallback überhaupt greift, und der Browser cached die alte SPA.
  app.use(
    express.static(publicPath, {
      setHeaders: (res) => {
        res.setHeader('Cache-Control', 'no-store, must-revalidate');
      },
    }),
  );

  // SPA fallback: serve index.html for all non-API routes
  app.get('*', (req, res, next) => {
    // Skip API routes
    if (req.path.startsWith('/api')) {
      return next();
    }
    // SPA-Wurzel darf NIE gecached werden – sonst sieht der Browser nach einem
    // Deploy weiterhin die alte index.html mit alten Asset-Hashes.
    res.setHeader('Cache-Control', 'no-store, must-revalidate');
    res.sendFile(path.join(publicPath, 'index.html'));
  });
}

// Error handling
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
// kaschiert und landen als "Interner Serverfehler" beim User.
//
// Multer-Errors werden hier sauber abgefangen statt als 500 zu enden:
//   - MulterError (z.B. LIMIT_FILE_SIZE)        → 413
//   - LIMIT_UNEXPECTED_FILE / -PART_COUNT etc.  → 400
//   - fileFilter-Errors (unzulässiger Typ)      → 415
// Pentest 2026-05-30, INFO: WebP/GIF-Uploads (und alle anderen vom
// fileFilter abgelehnten Typen) lieferten vorher 500 mit Stack-Trace.
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
  console.error(err?.stack || err);

  // Multer-spezifische Fehler (importiert as namespace)
  if (err?.name === 'MulterError') {
    const code = err.code;
    if (code === 'LIMIT_FILE_SIZE') {
      return res.status(413).json({ success: false, error: 'Datei ist zu groß' });
    }
    return res.status(400).json({
      success: false,
      error: 'Upload-Fehler: ' + (err.message || code || 'unbekannt'),
    });
  }

  // fileFilter hat cb(new Error(...), false) gerufen – kein MulterError,
  // sondern unsere eigene Reject-Nachricht ("Nur PDF, JPG, ... erlaubt").
  const msg = typeof err?.message === 'string' ? err.message : '';
  if (/sind erlaubt|nicht erlaubt/i.test(msg)) {
    return res.status(415).json({ success: false, error: msg });
  }

  const status = typeof err?.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
  let message = 'Interner Serverfehler';
  if (status === 413) message = 'Anfrage zu groß';
  else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
    message = 'Ungültiges JSON';
  }
  res.status(status).json({ success: false, error: message });
});

// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
const LISTEN_ADDR = process.env.LISTEN_ADDR
  || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');

// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
starteHeartbeatMonitor();

app.listen(PORT as number, LISTEN_ADDR, () => {
  console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
  // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
  startBirthdayScheduler();
  startContractStatusScheduler();
  startSecurityMonitorScheduler();
  startBlzUpdateScheduler();
});
