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:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+320
View File
@@ -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)
}