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
+327
View File
@@ -0,0 +1,327 @@
// Workflow ("Consumption-Regeln") HTTP handlers (see
// internal/storage/workflows.go):
//
// GET/POST /api/workflows GET/PUT/DELETE /api/workflows/{id}
// PUT /api/workflows/{id}/actions
// POST /api/workflows/{id}/test
// GET /api/workflows/{id}/runs
//
// Workflow administration (CRUD + action bulk replace) requires domain_admin
// (s.authAdmin, enforced in server.go). The dry-run test and the runs overview
// are normal authenticated tenant actions (s.auth). Ownership is enforced in
// the store layer (id+tenant_id). Every mutation is audit-logged, incl. failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type workflowRequest struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
TriggerType string `json:"trigger_type"`
ConditionTree json.RawMessage `json:"condition_tree"`
Priority int `json:"priority"`
}
// handleListWorkflows handles GET /api/workflows (without actions).
func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
workflows, err := s.store.ListWorkflows(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list workflows failed")
return
}
writeJSON(w, http.StatusOK, workflows)
}
// handleGetWorkflow handles GET /api/workflows/{id} (resolved with actions).
func (s *Server) handleGetWorkflow(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
}
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
if err != nil {
if errors.Is(err, storage.ErrWorkflowNotFound) {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
writeError(w, http.StatusInternalServerError, "get workflow failed")
return
}
writeJSON(w, http.StatusOK, wf)
}
// handleCreateWorkflow handles POST /api/workflows (domain_admin+).
func (s *Server) handleCreateWorkflow(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req workflowRequest
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
}
wf, err := s.store.CreateWorkflow(r.Context(), *sess.TenantID, storage.CreateWorkflowRequest{
Name: req.Name, Enabled: req.Enabled, TriggerType: req.TriggerType,
ConditionTree: req.ConditionTree, Priority: req.Priority, CreatedBy: &sess.UserID,
})
if err != nil {
status := workflowErrorStatus(err)
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_create err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "create workflow failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_create id:" + strconv.FormatInt(wf.ID, 10) + " name:" + wf.Name,
})
writeJSON(w, http.StatusCreated, wf)
}
// handleUpdateWorkflow handles PUT /api/workflows/{id} (domain_admin+).
func (s *Server) handleUpdateWorkflow(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 workflowRequest
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
}
err = s.store.UpdateWorkflow(r.Context(), id, *sess.TenantID, storage.UpdateWorkflowRequest{
Name: req.Name, Enabled: req.Enabled, TriggerType: req.TriggerType,
ConditionTree: req.ConditionTree, Priority: req.Priority,
})
if err != nil {
status := workflowErrorStatus(err)
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "update workflow failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_update id:" + strconv.FormatInt(id, 10),
})
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload workflow failed")
return
}
writeJSON(w, http.StatusOK, wf)
}
// handleDeleteWorkflow handles DELETE /api/workflows/{id} (domain_admin+).
func (s *Server) handleDeleteWorkflow(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.DeleteWorkflow(r.Context(), id, *sess.TenantID); err != nil {
status := http.StatusNotFound
if !errors.Is(err, storage.ErrWorkflowNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, "delete workflow failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowDelete, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_delete id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
type workflowActionsRequest struct {
Actions []struct {
ActionType string `json:"action_type"`
ActionConfig json.RawMessage `json:"action_config"`
} `json:"actions"`
}
// handleSetWorkflowActions handles PUT /api/workflows/{id}/actions (bulk
// replace, domain_admin+).
func (s *Server) handleSetWorkflowActions(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 workflowActionsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
actions := make([]storage.WorkflowActionInput, 0, len(req.Actions))
for _, a := range req.Actions {
actions = append(actions, storage.WorkflowActionInput{ActionType: a.ActionType, ActionConfig: a.ActionConfig})
}
if err := s.store.SetWorkflowActions(r.Context(), id, *sess.TenantID, actions); err != nil {
status := workflowErrorStatus(err)
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_actions_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "set workflow actions failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_actions_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(actions)),
})
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload workflow failed")
return
}
writeJSON(w, http.StatusOK, wf)
}
type workflowTestRequest struct {
DocumentID *int64 `json:"document_id"`
RawText string `json:"raw_text"`
}
// handleTestWorkflow handles POST /api/workflows/{id}/test. Pure dry-run: it
// reports which leaves matched and which actions WOULD run, never executing
// them. Evaluated against an existing document (document_id) or a synthetic
// document built from raw_text.
func (s *Server) handleTestWorkflow(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 workflowTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
res, err := s.store.TestWorkflow(r.Context(), id, *sess.TenantID, req.DocumentID, req.RawText)
if err != nil {
status := workflowErrorStatus(err)
if errors.Is(err, storage.ErrDocumentNotFound) {
status = http.StatusNotFound
}
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowRun, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_test id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "test workflow failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowRun, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_test id:" + strconv.FormatInt(id, 10) + " matched:" + strconv.FormatBool(res.Matched),
})
writeJSON(w, http.StatusOK, res)
}
// handleListWorkflowRuns handles GET /api/workflows/{id}/runs (optional
// ?limit=).
func (s *Server) handleListWorkflowRuns(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
}
limit := 0
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
limit = n
}
runs, err := s.store.ListWorkflowRuns(r.Context(), id, *sess.TenantID, limit)
if err != nil {
if errors.Is(err, storage.ErrWorkflowNotFound) {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
writeError(w, http.StatusInternalServerError, "list workflow runs failed")
return
}
writeJSON(w, http.StatusOK, runs)
}
// workflowErrorStatus maps store errors from the workflow write path to HTTP
// status codes.
func workflowErrorStatus(err error) int {
switch {
case errors.Is(err, storage.ErrWorkflowNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrDuplicateWorkflowName):
return http.StatusConflict
case errors.Is(err, storage.ErrInvalidConditionTree), errors.Is(err, storage.ErrInvalidWorkflowAction):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
// workflowErrorMessage returns the store error's message for the client on the
// validation cases (safe, user-actionable), otherwise the generic fallback.
func workflowErrorMessage(err error, fallback string) string {
switch {
case errors.Is(err, storage.ErrInvalidConditionTree), errors.Is(err, storage.ErrInvalidWorkflowAction):
return err.Error()
case errors.Is(err, storage.ErrWorkflowNotFound):
return "workflow not found"
case errors.Is(err, storage.ErrDuplicateWorkflowName):
return "workflow with this name already exists"
default:
return fallback
}
}