Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
328 lines
12 KiB
Go
328 lines
12 KiB
Go
// 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
|
|
}
|
|
}
|