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