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