Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
248 lines
9.3 KiB
Go
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
|
|
}
|