Files
archivdms/internal/api/taxonomy_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

292 lines
10 KiB
Go

// Structured-entity HTTP handlers for tags/document_types/correspondents
// (see internal/storage/taxonomy.go) plus manual tag attach/detach:
//
// GET/POST /api/tags PATCH/DELETE /api/tags/{id}
// GET/POST /api/document-types PATCH/DELETE /api/document-types/{id}
// GET/POST /api/correspondents PATCH/DELETE /api/correspondents/{id}
// POST/DELETE /api/documents/{id}/tags/{tagId}
//
// All routes require s.auth(...) (authenticated + tenant context).
// Ownership is enforced in the store layer (id+tenant_id), analogous to
// internal/api/reminder_handlers.go. Every mutation is audit-logged,
// including failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type taxonomyEntityRequest struct {
Name string `json:"name"`
Color string `json:"color"`
MatchAlgorithm string `json:"match_algorithm"`
MatchPattern string `json:"match_pattern"`
CaseSensitive bool `json:"case_sensitive"`
BarcodeValue string `json:"barcode_value"`
}
func taxonomyEventType(kind string) string {
switch kind {
case "tags":
return "tag"
case "document_types":
return "document_type"
case "correspondents":
return "correspondent"
default:
return kind
}
}
// handleListTaxonomy handles GET /api/{tags,document-types,correspondents}.
func (s *Server) handleListTaxonomy(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
entities, err := s.store.ListTaxonomyEntities(r.Context(), kind, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list "+kind+" failed")
return
}
writeJSON(w, http.StatusOK, entities)
}
}
// handleCreateTaxonomy handles POST /api/{tags,document-types,correspondents}.
func (s *Server) handleCreateTaxonomy(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req taxonomyEntityRequest
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
}
entity, err := s.store.CreateTaxonomyEntity(r.Context(), kind, *sess.TenantID, storage.TaxonomyEntityRequest{
Name: req.Name, Color: req.Color, MatchAlgorithm: req.MatchAlgorithm,
MatchPattern: req.MatchPattern, CaseSensitive: req.CaseSensitive, BarcodeValue: req.BarcodeValue,
})
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrDuplicateTaxonomyName) {
status = http.StatusConflict
}
s.audlog.Log(audit.Entry{EventType: taxonomyEventType(kind) + "_create", Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
writeError(w, status, "create "+kind+" failed")
return
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_create", Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "id:" + strconv.FormatInt(entity.ID, 10) + " name:" + entity.Name,
})
writeJSON(w, http.StatusCreated, entity)
}
}
// handleUpdateTaxonomy handles PATCH /api/{tags,document-types,correspondents}/{id}.
func (s *Server) handleUpdateTaxonomy(kind string) http.HandlerFunc {
return func(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 taxonomyEntityRequest
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
}
entity, err := s.store.UpdateTaxonomyEntity(r.Context(), kind, id, *sess.TenantID, storage.TaxonomyEntityRequest{
Name: req.Name, Color: req.Color, MatchAlgorithm: req.MatchAlgorithm,
MatchPattern: req.MatchPattern, CaseSensitive: req.CaseSensitive, BarcodeValue: req.BarcodeValue,
})
if err != nil {
status := http.StatusNotFound
if errors.Is(err, storage.ErrDuplicateTaxonomyName) {
status = http.StatusConflict
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_update", Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
writeError(w, status, "update "+kind+" failed")
return
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_update", Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "id:" + strconv.FormatInt(entity.ID, 10),
})
writeJSON(w, http.StatusOK, entity)
}
}
// handleDeleteTaxonomy handles DELETE /api/{tags,document-types,correspondents}/{id}.
func (s *Server) handleDeleteTaxonomy(kind string) http.HandlerFunc {
return func(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.DeleteTaxonomyEntity(r.Context(), kind, id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_delete", Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
writeError(w, http.StatusNotFound, "delete "+kind+" failed")
return
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_delete", Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
}
// handleAttachTag handles POST /api/documents/{id}/tags/{tagId} (manual
// tag attach). Verifies both the document and the tag belong to the
// caller's tenant before inserting the document_tags row.
func (s *Server) handleAttachTag(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
}
tagID, err := strconv.ParseInt(r.PathValue("tagId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
tags, err := s.store.ListTaxonomyEntities(r.Context(), "tags", *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "attach tag failed")
return
}
found := false
for _, t := range tags {
if t.ID == tagID {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "tag not found")
return
}
if err := s.store.AttachTag(r.Context(), docID, tagID); err != nil {
s.audlog.Log(audit.Entry{
EventType: "tag_attach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
})
writeError(w, http.StatusInternalServerError, "attach tag failed")
return
}
s.audlog.Log(audit.Entry{
EventType: "tag_attach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "tag_id:" + strconv.FormatInt(tagID, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "attached"})
}
// handleDetachTag handles DELETE /api/documents/{id}/tags/{tagId}.
func (s *Server) handleDetachTag(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
}
tagID, err := strconv.ParseInt(r.PathValue("tagId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
if err := s.store.DetachTag(r.Context(), docID, tagID); err != nil {
s.audlog.Log(audit.Entry{
EventType: "tag_detach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
})
writeError(w, http.StatusInternalServerError, "detach tag failed")
return
}
s.audlog.Log(audit.Entry{
EventType: "tag_detach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "tag_id:" + strconv.FormatInt(tagID, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "detached"})
}
// handleListDocumentTags handles GET /api/documents/{id}/tags.
func (s *Server) handleListDocumentTags(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
}
tags, err := s.store.ListDocumentTags(r.Context(), docID, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list document tags failed")
return
}
writeJSON(w, http.StatusOK, tags)
}