diff --git a/config.example.toml b/config.example.toml
index 3cc4a2a..2534cd0 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -69,6 +69,12 @@ retry_cron = "0 */5 * * * *"
# --- Lokale Speicher (Signaturen / Bild-Notizen) --------------------------
[signature]
storage_dir = "./data/signatures"
+# Aufbewahrungsfrist der Unterschrifts-Dateien in Tagen. Ein Cron löscht
+# ältere Dateien (Unterschriften werden NICHT mehr beim Report-Upload gelöscht).
+# 0 = nie automatisch löschen.
+retention_days = 90
+# Cron (6-stellig, inkl. Sekunden) für den Signatur-Cleanup (täglich 04:00).
+cleanup_cron = "0 0 4 * * *"
[attachment]
storage_dir = "./data/attachments"
diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs
index 00f6f4e..a50c1a9 100644
--- a/crates/api/src/config.rs
+++ b/crates/api/src/config.rs
@@ -102,7 +102,12 @@ impl Config {
app_names = ?self.gsd.app_names,
"cfg.gsd"
);
- tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature");
+ tracing::info!(
+ storage_dir = %self.signature.storage_dir,
+ retention_days = self.signature.retention_days,
+ cleanup_cron = %self.signature.cleanup_cron,
+ "cfg.signature"
+ );
tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)");
tracing::info!(
storage_dir = %self.report.storage_dir,
@@ -265,12 +270,22 @@ pub struct SignatureConfig {
/// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt).
#[serde(default = "default_signature_dir")]
pub storage_dir: String,
+ /// Aufbewahrungsfrist der lokalen Unterschrifts-Dateien in Tagen. Ein
+ /// Cron-Job (`cleanup_cron`) löscht Dateien, die älter sind. `0` = Cleanup
+ /// AUS (Unterschriften werden nie automatisch gelöscht).
+ #[serde(default = "default_signature_retention_days")]
+ pub retention_days: u32,
+ /// Cron (6-stellig, inkl. Sekunden) für den Signatur-Cleanup.
+ #[serde(default = "default_signature_cleanup_cron")]
+ pub cleanup_cron: String,
}
impl Default for SignatureConfig {
fn default() -> Self {
Self {
storage_dir: default_signature_dir(),
+ retention_days: default_signature_retention_days(),
+ cleanup_cron: default_signature_cleanup_cron(),
}
}
}
@@ -279,6 +294,15 @@ fn default_signature_dir() -> String {
"./data/signatures".to_string()
}
+fn default_signature_retention_days() -> u32 {
+ 90
+}
+
+fn default_signature_cleanup_cron() -> String {
+ // täglich 04:00
+ "0 0 4 * * *".to_string()
+}
+
/// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die
/// Bilder pro Belegnummer in einem Unterordner (`
//…`).
#[derive(Debug, Clone, Deserialize)]
diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs
index 4267016..b434e15 100644
--- a/crates/api/src/main.rs
+++ b/crates/api/src/main.rs
@@ -46,6 +46,7 @@ use holzleitner_application::usecases::{
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
};
use holzleitner_application::ports::DeliveryReportJobRepository;
+use holzleitner_application::ports::SignatureStorage;
use holzleitner_application::ports::{SyncRunRepository, SyncTrigger};
use holzleitner_infrastructure::auth::{
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
@@ -271,7 +272,6 @@ pub(crate) async fn run_app(
gsd_service.clone(),
attachment_repository.clone(),
attachment_storage.clone(),
- signature_storage.clone(),
report_sink.clone(),
));
@@ -368,7 +368,7 @@ pub(crate) async fn run_app(
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
delivery_completion_repository,
- signature_storage,
+ signature_storage.clone(),
car_repository.clone(),
if cfg.erp.writeback_enabled {
Some(push_completion_to_erp.clone())
@@ -631,6 +631,51 @@ pub(crate) async fn run_app(
None
};
+ // --- Signatur-Cleanup-Scheduler ------------------------------------
+ // Unterschriften bleiben nach dem Report-Upload bewusst erhalten (wir
+ // brauchen die Dateien) und werden hier periodisch gelöscht, sobald sie
+ // älter als die Aufbewahrungsfrist sind. retention_days = 0 ⇒ deaktiviert.
+ let _signature_cleanup_scheduler = if cfg.signature.retention_days > 0 {
+ let scheduler = JobScheduler::new()
+ .await
+ .context("Signatur-Cleanup-JobScheduler konnte nicht erstellt werden")?;
+ let signatures = signature_storage.clone();
+ let max_age = Duration::from_secs(cfg.signature.retention_days as u64 * 86_400);
+ let job = Job::new_async(cfg.signature.cleanup_cron.as_str(), move |_uuid, _lock| {
+ let signatures = signatures.clone();
+ Box::pin(async move {
+ match signatures.delete_older_than(max_age).await {
+ Ok(0) => {}
+ Ok(n) => tracing::info!(
+ deleted = n,
+ "signature_cleanup: abgelaufene Unterschriften gelöscht"
+ ),
+ Err(e) => tracing::error!(error = %e, "signature_cleanup fehlgeschlagen"),
+ }
+ })
+ })
+ .context("Signatur-Cleanup-Job konnte nicht erstellt werden")?;
+ scheduler
+ .add(job)
+ .await
+ .context("Signatur-Cleanup-Job add fehlgeschlagen")?;
+ scheduler
+ .start()
+ .await
+ .context("Signatur-Cleanup-Scheduler-Start fehlgeschlagen")?;
+ tracing::info!(
+ cron = %cfg.signature.cleanup_cron,
+ retention_days = cfg.signature.retention_days,
+ "signature_cleanup scheduler gestartet"
+ );
+ Some(scheduler)
+ } else {
+ tracing::info!(
+ "signature_cleanup deaktiviert (signature.retention_days = 0) — Unterschriften werden nie automatisch gelöscht"
+ );
+ None
+ };
+
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
.parse()
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
diff --git a/crates/application/src/ports/signature_storage.rs b/crates/application/src/ports/signature_storage.rs
index 770c863..469478c 100644
--- a/crates/application/src/ports/signature_storage.rs
+++ b/crates/application/src/ports/signature_storage.rs
@@ -8,6 +8,8 @@
//! `holzleitner-infrastructure`. Der Use Case erhält eine relative
//! Referenz zurück, die in `delivery_completions` persistiert wird.
+use std::time::Duration;
+
use async_trait::async_trait;
use uuid::Uuid;
@@ -47,8 +49,14 @@ pub trait SignatureStorage: Send + Sync {
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
async fn load(&self, reference: &str) -> Result