Files
archivdms/internal/api/retention_rule_handlers.go
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

248 lines
9.3 KiB
Go

// 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
}