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.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user