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