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,320 @@
|
||||
// Custom-fields HTTP handlers (see internal/storage/custom_fields.go):
|
||||
//
|
||||
// GET/POST /api/custom-fields PATCH/DELETE /api/custom-fields/{id}
|
||||
// GET/PUT /api/document-types/{id}/fields
|
||||
// GET/PUT /api/documents/{id}/fields
|
||||
//
|
||||
// Definition create/update/delete require domain_admin (s.authAdmin); listing
|
||||
// and value-setting require an authenticated tenant context (s.auth).
|
||||
// Ownership is enforced in the store layer (id+tenant_id). Every mutation is
|
||||
// audit-logged, including failures, using the document lifecycle event types.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
type customFieldRequest struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
FieldType string `json:"field_type"`
|
||||
EnumOptions []string `json:"enum_options"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// handleListCustomFields handles GET /api/custom-fields.
|
||||
func (s *Server) handleListCustomFields(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
defs, err := s.store.ListCustomFieldDefs(r.Context(), *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list custom fields failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, defs)
|
||||
}
|
||||
|
||||
// handleCreateCustomField handles POST /api/custom-fields (domain_admin+).
|
||||
func (s *Server) handleCreateCustomField(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
var req customFieldRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Label == "" || req.FieldType == "" {
|
||||
writeError(w, http.StatusBadRequest, "name, label and field_type are required")
|
||||
return
|
||||
}
|
||||
|
||||
def, err := s.store.CreateCustomFieldDef(r.Context(), *sess.TenantID, storage.CustomFieldDefRequest{
|
||||
Name: req.Name, Label: req.Label, FieldType: req.FieldType,
|
||||
EnumOptions: req.EnumOptions, Currency: req.Currency,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrDuplicateCustomFieldName) {
|
||||
status = http.StatusConflict
|
||||
} else if strings.Contains(err.Error(), "invalid field_type") {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_create err:" + err.Error()})
|
||||
writeError(w, status, "create custom field failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "custom_field_create id:" + strconv.FormatInt(def.ID, 10) + " name:" + def.Name,
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, def)
|
||||
}
|
||||
|
||||
// handleUpdateCustomField handles PATCH /api/custom-fields/{id} (domain_admin+).
|
||||
func (s *Server) handleUpdateCustomField(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 customFieldRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Label == "" {
|
||||
writeError(w, http.StatusBadRequest, "label is required")
|
||||
return
|
||||
}
|
||||
|
||||
def, err := s.store.UpdateCustomFieldDef(r.Context(), id, *sess.TenantID, req.Label, req.EnumOptions, req.Currency)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if !errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "update custom field failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "custom_field_update id:" + strconv.FormatInt(def.ID, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, def)
|
||||
}
|
||||
|
||||
// handleDeleteCustomField handles DELETE /api/custom-fields/{id} (domain_admin+).
|
||||
func (s *Server) handleDeleteCustomField(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.DeleteCustomFieldDef(r.Context(), id, *sess.TenantID); err != nil {
|
||||
status := http.StatusNotFound
|
||||
msg := "delete custom field failed"
|
||||
if errors.Is(err, storage.ErrCustomFieldInUse) {
|
||||
status = http.StatusConflict
|
||||
msg = "custom field still has values"
|
||||
} else if !errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "custom_field_delete id:" + strconv.FormatInt(id, 10),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// --- document-type field assignments ---
|
||||
|
||||
type docTypeFieldAssignmentRequest struct {
|
||||
FieldID int64 `json:"field_id"`
|
||||
Required bool `json:"required"`
|
||||
Visible bool `json:"visible"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// handleListDocumentTypeFields handles GET /api/document-types/{id}/fields.
|
||||
func (s *Server) handleListDocumentTypeFields(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
fields, err := s.store.ListDocumentTypeFields(r.Context(), docTypeID, *sess.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrTaxonomyNotFound) {
|
||||
writeError(w, http.StatusNotFound, "document type not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "list document type fields failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fields)
|
||||
}
|
||||
|
||||
// handleSetDocumentTypeFields handles PUT /api/document-types/{id}/fields
|
||||
// (bulk replace, domain_admin+).
|
||||
func (s *Server) handleSetDocumentTypeFields(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document type id")
|
||||
return
|
||||
}
|
||||
var reqs []docTypeFieldAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
|
||||
return
|
||||
}
|
||||
assignments := make([]storage.DocumentTypeFieldAssignment, 0, len(reqs))
|
||||
for _, a := range reqs {
|
||||
assignments = append(assignments, storage.DocumentTypeFieldAssignment{
|
||||
FieldID: a.FieldID, Required: a.Required, Visible: a.Visible, SortOrder: a.SortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.store.SetDocumentTypeFields(r.Context(), docTypeID, *sess.TenantID, assignments); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrTaxonomyNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "doc_type_fields_set doc_type:" + strconv.FormatInt(docTypeID, 10) + " err:" + err.Error()})
|
||||
writeError(w, status, "set document type fields failed")
|
||||
return
|
||||
}
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
Success: true, Detail: "doc_type_fields_set doc_type:" + strconv.FormatInt(docTypeID, 10) + " count:" + strconv.Itoa(len(assignments)),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// --- document field values ---
|
||||
|
||||
// handleListDocumentFieldValues handles GET /api/documents/{id}/fields.
|
||||
func (s *Server) handleListDocumentFieldValues(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
|
||||
}
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
values, err := s.store.ListDocumentFieldValues(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list document field values failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
// handleSetDocumentFieldValues handles PUT /api/documents/{id}/fields (batch).
|
||||
func (s *Server) handleSetDocumentFieldValues(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
|
||||
}
|
||||
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
var inputs []storage.DocumentFieldValueInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
|
||||
return
|
||||
}
|
||||
|
||||
changed, err := s.store.SetDocumentFieldValues(r.Context(), docID, *sess.TenantID, inputs)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "set document field values failed"
|
||||
if errors.Is(err, storage.ErrRequiredFieldMissing) {
|
||||
status = http.StatusBadRequest
|
||||
msg = err.Error()
|
||||
} 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.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: "custom_field_values_set err:" + err.Error(),
|
||||
})
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
for _, name := range changed {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
|
||||
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "custom_field:" + name + " changed",
|
||||
})
|
||||
}
|
||||
// Re-sync the search index: custom-field values are (Phase 1) not yet a
|
||||
// dedicated indexed field, but the document projection is refreshed so the
|
||||
// index stays consistent and a later phase can start indexing field text
|
||||
// without a backfill gap. Best-effort, never fails the request.
|
||||
s.store.SyncIndex(r.Context(), docID)
|
||||
|
||||
values, err := s.store.ListDocumentFieldValues(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "reload document field values failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
Reference in New Issue
Block a user