Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
405 lines
15 KiB
Go
405 lines
15 KiB
Go
// Classification-template ("Klassifizierungsvorlagen") HTTP handlers (see
|
|
// internal/storage/classification_templates.go +
|
|
// classification_templates_apply.go):
|
|
//
|
|
// GET/POST /api/classification-templates GET/PUT/DELETE /api/classification-templates/{id}
|
|
// PUT /api/classification-templates/{id}/tags
|
|
// PUT /api/classification-templates/{id}/field-defaults
|
|
// POST /api/documents/{id}/apply-template
|
|
//
|
|
// Template administration (CRUD + tag / field-default bulk replace) requires
|
|
// domain_admin (s.authAdmin). Applying a template to a document is a normal
|
|
// working action and only requires an authenticated tenant context (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"
|
|
"strings"
|
|
|
|
"archivdms/internal/audit"
|
|
"archivdms/internal/storage"
|
|
)
|
|
|
|
type templateRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
DocTypeID *int64 `json:"doc_type_id"`
|
|
RetainYears *int `json:"retain_years"`
|
|
Active *bool `json:"active"`
|
|
TitleTemplate *string `json:"title_template"`
|
|
}
|
|
|
|
// handleListTemplates handles GET /api/classification-templates (optional
|
|
// ?doc_type_id= filter).
|
|
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID == nil {
|
|
writeError(w, http.StatusForbidden, "tenant context required")
|
|
return
|
|
}
|
|
var docTypeID *int64
|
|
if raw := r.URL.Query().Get("doc_type_id"); raw != "" {
|
|
id, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid doc_type_id")
|
|
return
|
|
}
|
|
docTypeID = &id
|
|
}
|
|
tmpls, err := s.store.ListTemplates(r.Context(), *sess.TenantID, docTypeID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "list classification templates failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpls)
|
|
}
|
|
|
|
// handleGetTemplate handles GET /api/classification-templates/{id} (resolved).
|
|
func (s *Server) handleGetTemplate(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
|
|
}
|
|
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
|
writeError(w, http.StatusNotFound, "classification template not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "get classification template failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpl)
|
|
}
|
|
|
|
// handleCreateTemplate handles POST /api/classification-templates (domain_admin+).
|
|
func (s *Server) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID == nil {
|
|
writeError(w, http.StatusForbidden, "tenant context required")
|
|
return
|
|
}
|
|
var req templateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
active := true
|
|
if req.Active != nil {
|
|
active = *req.Active
|
|
}
|
|
if req.TitleTemplate != nil {
|
|
if err := storage.ValidateTitleTemplate(*req.TitleTemplate); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
tmpl, err := s.store.CreateTemplate(r.Context(), *sess.TenantID, storage.CreateTemplateRequest{
|
|
Name: req.Name, Description: req.Description, DocTypeID: req.DocTypeID,
|
|
RetainYears: req.RetainYears, Active: active, CreatedBy: &sess.UserID,
|
|
TitleTemplate: req.TitleTemplate,
|
|
})
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, storage.ErrDuplicateTemplateName) {
|
|
status = http.StatusConflict
|
|
}
|
|
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_create err:" + err.Error()})
|
|
writeError(w, status, "create classification template failed")
|
|
return
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateCreate, Username: sess.Username, TenantID: sess.TenantID,
|
|
Success: true, Detail: "template_create id:" + strconv.FormatInt(tmpl.ID, 10) + " name:" + tmpl.Name,
|
|
})
|
|
writeJSON(w, http.StatusCreated, tmpl)
|
|
}
|
|
|
|
// handleUpdateTemplate handles PUT /api/classification-templates/{id} (domain_admin+).
|
|
func (s *Server) handleUpdateTemplate(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 templateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
active := true
|
|
if req.Active != nil {
|
|
active = *req.Active
|
|
}
|
|
if req.TitleTemplate != nil {
|
|
if err := storage.ValidateTitleTemplate(*req.TitleTemplate); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
err = s.store.UpdateTemplate(r.Context(), id, *sess.TenantID, storage.UpdateTemplateRequest{
|
|
Name: req.Name, Description: req.Description, DocTypeID: req.DocTypeID,
|
|
RetainYears: req.RetainYears, Active: active,
|
|
TitleTemplate: req.TitleTemplate,
|
|
})
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, storage.ErrDuplicateTemplateName) {
|
|
status = http.StatusConflict
|
|
}
|
|
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
|
writeError(w, status, "update classification template failed")
|
|
return
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
|
Success: true, Detail: "template_update id:" + strconv.FormatInt(id, 10),
|
|
})
|
|
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "reload classification template failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpl)
|
|
}
|
|
|
|
// handleDeleteTemplate handles DELETE /api/classification-templates/{id} (domain_admin+).
|
|
func (s *Server) handleDeleteTemplate(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.DeleteTemplate(r.Context(), id, *sess.TenantID); err != nil {
|
|
status := http.StatusNotFound
|
|
if !errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
|
writeError(w, status, "delete classification template failed")
|
|
return
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateDelete, Username: sess.Username, TenantID: sess.TenantID,
|
|
Success: true, Detail: "template_delete id:" + strconv.FormatInt(id, 10),
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
type templateTagsRequest struct {
|
|
TagIDs []int64 `json:"tag_ids"`
|
|
}
|
|
|
|
// handleSetTemplateTags handles PUT /api/classification-templates/{id}/tags (domain_admin+).
|
|
func (s *Server) handleSetTemplateTags(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 templateTagsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if err := s.store.SetTemplateTags(r.Context(), id, *sess.TenantID, req.TagIDs); err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, storage.ErrTaxonomyNotFound) {
|
|
status = http.StatusBadRequest
|
|
}
|
|
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_tags_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
|
writeError(w, status, "set template tags failed")
|
|
return
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
|
Success: true, Detail: "template_tags_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(req.TagIDs)),
|
|
})
|
|
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "reload classification template failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpl)
|
|
}
|
|
|
|
type templateFieldDefaultRequest struct {
|
|
FieldID int64 `json:"field_id"`
|
|
ValueText *string `json:"value_text"`
|
|
ValueNumber *float64 `json:"value_number"`
|
|
ValueDate *string `json:"value_date"`
|
|
ValueBool *bool `json:"value_bool"`
|
|
Overwrite bool `json:"overwrite"`
|
|
}
|
|
|
|
// handleSetTemplateFieldDefaults handles PUT
|
|
// /api/classification-templates/{id}/field-defaults (bulk replace, domain_admin+).
|
|
func (s *Server) handleSetTemplateFieldDefaults(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 reqs []templateFieldDefaultRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
|
|
return
|
|
}
|
|
defaults := make([]storage.TemplateFieldDefaultInput, 0, len(reqs))
|
|
for _, d := range reqs {
|
|
defaults = append(defaults, storage.TemplateFieldDefaultInput{
|
|
FieldID: d.FieldID, ValueText: d.ValueText, ValueNumber: d.ValueNumber,
|
|
ValueDate: d.ValueDate, ValueBool: d.ValueBool, Overwrite: d.Overwrite,
|
|
})
|
|
}
|
|
if err := s.store.SetTemplateFieldDefaults(r.Context(), id, *sess.TenantID, defaults); err != nil {
|
|
status := http.StatusInternalServerError
|
|
msg := "set template field defaults failed"
|
|
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
|
|
status = http.StatusBadRequest
|
|
msg = "unknown custom field"
|
|
} else if strings.Contains(err.Error(), "invalid date") || strings.Contains(err.Error(), "not in enum options") {
|
|
status = http.StatusBadRequest
|
|
msg = err.Error()
|
|
}
|
|
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_field_defaults_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
|
writeError(w, status, msg)
|
|
return
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
|
Success: true, Detail: "template_field_defaults_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(defaults)),
|
|
})
|
|
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "reload classification template failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tmpl)
|
|
}
|
|
|
|
type applyTemplateRequest struct {
|
|
TemplateID int64 `json:"template_id"`
|
|
DryRun bool `json:"dry_run"`
|
|
Overwrite bool `json:"overwrite"`
|
|
}
|
|
|
|
// handleApplyTemplate handles POST /api/documents/{id}/apply-template. Any
|
|
// authenticated tenant user may apply a template (normal working action). With
|
|
// dry_run=true it only previews (no writes). A rejected retain_until shortening
|
|
// (RetainUntilBlocked) is still audit-logged for GoBD traceability.
|
|
func (s *Server) handleApplyTemplate(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID == nil {
|
|
writeError(w, http.StatusForbidden, "tenant context required")
|
|
return
|
|
}
|
|
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid document id")
|
|
return
|
|
}
|
|
var req applyTemplateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.TemplateID == 0 {
|
|
writeError(w, http.StatusBadRequest, "template_id is required")
|
|
return
|
|
}
|
|
|
|
docRef := strconv.FormatInt(docID, 10)
|
|
if req.DryRun {
|
|
res, err := s.store.PreviewApplyTemplate(r.Context(), docID, req.TemplateID, *sess.TenantID)
|
|
if err != nil {
|
|
s.writeTemplateApplyError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
return
|
|
}
|
|
|
|
res, err := s.store.ApplyTemplate(r.Context(), docID, req.TemplateID, *sess.TenantID, req.Overwrite)
|
|
if err != nil {
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateApplied, Username: sess.Username, TenantID: sess.TenantID,
|
|
DocumentID: docRef, Success: false, Detail: "template_apply template:" + strconv.FormatInt(req.TemplateID, 10) + " err:" + err.Error(),
|
|
})
|
|
s.writeTemplateApplyError(w, err)
|
|
return
|
|
}
|
|
detail := "template_apply template:" + strconv.FormatInt(req.TemplateID, 10) +
|
|
" tags_added:" + strconv.Itoa(len(res.TagsToAdd)) +
|
|
" fields_set:" + strconv.Itoa(len(res.FieldsToSet)) +
|
|
" fields_overwritten:" + strconv.Itoa(len(res.FieldsOverwritten))
|
|
if res.RetainUntilBlocked {
|
|
detail += " retain_until_shortening_rejected"
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventTemplateApplied, Username: sess.Username, TenantID: sess.TenantID,
|
|
DocumentID: docRef, Success: true, Detail: detail,
|
|
})
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
// writeTemplateApplyError maps store errors from the apply/preview path to HTTP
|
|
// status codes.
|
|
func (s *Server) writeTemplateApplyError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, storage.ErrDocumentNotFound):
|
|
writeError(w, http.StatusNotFound, "document not found")
|
|
case errors.Is(err, storage.ErrClassificationTemplateNotFound):
|
|
writeError(w, http.StatusNotFound, "classification template not found")
|
|
case errors.Is(err, storage.ErrRequiredFieldMissing):
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "apply template failed")
|
|
}
|
|
}
|