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