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