// GoBD retention-rule ("Aufbewahrungsregeln") HTTP handlers (see // internal/storage/retention_rules.go): // // GET /api/retention-rules list all rules of the tenant // POST /api/retention-rules create a rule // PATCH /api/retention-rules/{id} update a rule // DELETE /api/retention-rules/{id} delete a rule // GET /api/retention-rules/eligible documents eligible for disposition // GET /api/retention-rules/preview dry-run of ApplyRetentionRules (no write) // // Rules are compliance-critical (they define how long documents must be kept), // so create/update/delete require domain_admin (s.authAdmin). Reading (list, // eligible, preview) is a normal tenant action (s.auth). Ownership is enforced // in the store layer (id+tenant_id). Every mutation is audit-logged, including // failures. package api import ( "encoding/json" "errors" "net/http" "strconv" "archivdms/internal/audit" "archivdms/internal/storage" ) // retentionRuleRequest is the JSON body for create/update. Pointers where the // store field is a pointer, so "not set" round-trips correctly. type retentionRuleRequest struct { DocTypeID *int64 `json:"doc_type_id"` Name string `json:"name"` TriggerType string `json:"trigger_type"` TriggerReference string `json:"trigger_reference"` RetentionYears *int `json:"retention_years"` RetentionDays *int `json:"retention_days"` LegalBasis string `json:"legal_basis"` RequiresApprovalForDestroy *bool `json:"requires_approval_for_destroy"` DSGVOConflict *bool `json:"dsgvo_conflict"` Active *bool `json:"active"` } // toRule maps the request onto a storage.RetentionRule. requires_approval and // active default to true when omitted (safe GoBD default: keep approval on). func (req retentionRuleRequest) toRule() storage.RetentionRule { requiresApproval := true if req.RequiresApprovalForDestroy != nil { requiresApproval = *req.RequiresApprovalForDestroy } active := true if req.Active != nil { active = *req.Active } dsgvo := false if req.DSGVOConflict != nil { dsgvo = *req.DSGVOConflict } return storage.RetentionRule{ DocTypeID: req.DocTypeID, Name: req.Name, TriggerType: req.TriggerType, TriggerReference: req.TriggerReference, RetentionYears: req.RetentionYears, RetentionDays: req.RetentionDays, LegalBasis: req.LegalBasis, RequiresApprovalForDestroy: requiresApproval, DSGVOConflict: dsgvo, Active: active, } } // handleListRetentionRules handles GET /api/retention-rules. func (s *Server) handleListRetentionRules(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } rules, err := s.store.ListRetentionRules(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "list retention rules failed") return } writeJSON(w, http.StatusOK, rules) } // handleCreateRetentionRule handles POST /api/retention-rules (domain_admin+). func (s *Server) handleCreateRetentionRule(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } var req retentionRuleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } rule := req.toRule() rule.CreatedBy = &sess.UserID created, err := s.store.CreateRetentionRule(r.Context(), *sess.TenantID, rule) if err != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventRetentionRuleCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "retention_rule_create err:" + err.Error(), }) writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "create retention rule failed")) return } s.audlog.Log(audit.Entry{ EventType: audit.EventRetentionRuleCreate, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "retention_rule_create id:" + strconv.FormatInt(created.ID, 10) + " name:" + created.Name, }) writeJSON(w, http.StatusCreated, created) } // handleUpdateRetentionRule handles PATCH /api/retention-rules/{id} (domain_admin+). func (s *Server) handleUpdateRetentionRule(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } var req retentionRuleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } updated, err := s.store.UpdateRetentionRule(r.Context(), id, *sess.TenantID, req.toRule()) if err != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventRetentionRuleUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "retention_rule_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(), }) writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "update retention rule failed")) return } s.audlog.Log(audit.Entry{ EventType: audit.EventRetentionRuleUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "retention_rule_update id:" + strconv.FormatInt(id, 10), }) writeJSON(w, http.StatusOK, updated) } // handleDeleteRetentionRule handles DELETE /api/retention-rules/{id} (domain_admin+). func (s *Server) handleDeleteRetentionRule(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } if err := s.store.DeleteRetentionRule(r.Context(), id, *sess.TenantID); err != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventRetentionRuleDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "retention_rule_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(), }) writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "delete retention rule failed")) return } s.audlog.Log(audit.Entry{ EventType: audit.EventRetentionRuleDelete, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "retention_rule_delete id:" + strconv.FormatInt(id, 10), }) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // handleListEligibleForDisposition handles GET /api/retention-rules/eligible: // documents whose retention has expired but which are not yet in the trash. func (s *Server) handleListEligibleForDisposition(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } docs, err := s.store.ListEligibleForDisposition(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "list eligible-for-disposition documents failed") return } writeJSON(w, http.StatusOK, docs) } // handlePreviewRetentionRules handles GET /api/retention-rules/preview: a // dry-run of ApplyRetentionRules for the current tenant. No writes. func (s *Server) handlePreviewRetentionRules(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } preview, err := s.store.PreviewRetentionRules(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "preview retention rules failed") return } if preview == nil { preview = []storage.RetentionPreview{} } writeJSON(w, http.StatusOK, preview) } // retentionRuleErrStatus maps store errors to HTTP status codes. Validation // errors (bad trigger_type, missing retention period, bad fixed_date) surface as // 400; not-found as 404; everything else 500. func retentionRuleErrStatus(err error) int { if errors.Is(err, storage.ErrRetentionRuleNotFound) { return http.StatusNotFound } if isRetentionValidationErr(err) { return http.StatusBadRequest } return http.StatusInternalServerError } // retentionRuleErrMsg returns the validation message verbatim (safe, no PII) so // the frontend can show it, or a generic fallback otherwise. func retentionRuleErrMsg(err error, fallback string) string { if errors.Is(err, storage.ErrRetentionRuleNotFound) { return "retention rule not found" } if isRetentionValidationErr(err) { return err.Error() } return fallback } // isRetentionValidationErr reports whether err is a validateRetentionRule // cross-field error (all prefixed "retention rule:" in the store). func isRetentionValidationErr(err error) bool { if err == nil || errors.Is(err, storage.ErrRetentionRuleNotFound) { return false } msg := err.Error() const prefix = "retention rule: " return len(msg) >= len(prefix) && msg[:len(prefix)] == prefix }