Documentation
¶
Index ¶
- Constants
- Variables
- func FilterEditableFormData(formData map[string]any, permissions map[string]approval.Permission) map[string]any
- func MergeFormData(instance *approval.Instance, formData map[string]any, ...)
- type ActionLogParams
- type FlowDefinitionService
- func (s *FlowDefinitionService) ValidateConditionAggregates(nodeData map[string]approval.NodeData, fields []approval.FormFieldDefinition) error
- func (*FlowDefinitionService) ValidateFieldPermissions(nodeData map[string]approval.NodeData, fields []approval.FormFieldDefinition) error
- func (*FlowDefinitionService) ValidateFlowDefinition(def *approval.FlowDefinition) (map[string]approval.NodeData, error)
- func (*FlowDefinitionService) ValidateFormFields(fields []approval.FormFieldDefinition) error
- type InstanceService
- func (*InstanceService) ApplyRollbackFormData(ctx context.Context, db orm.DB, instance *approval.Instance, ...) error
- func (*InstanceService) LoadForUpdate(ctx context.Context, db orm.DB, instanceID string, ...) (*approval.Instance, error)
- func (s *InstanceService) Transition(ctx context.Context, db orm.DB, instance *approval.Instance, ...) error
- type NodeService
- func (s *NodeService) AdvanceCCNodeIfAllRead(ctx context.Context, db orm.DB, instanceID string, records []approval.CCRecord) error
- func (s *NodeService) HandleNodeCompletion(ctx context.Context, db orm.DB, instance *approval.Instance, ...) ([]approval.DomainEvent, error)
- func (s *NodeService) TriggerNodeCC(ctx context.Context, db orm.DB, instance *approval.Instance, ...) error
- type TaskContext
- type TaskContextLoadOptions
- type TaskService
- func (s *TaskService) ActivateDependentTasks(ctx context.Context, db orm.DB, instance *approval.Instance, ...) error
- func (*TaskService) ActivateNextSequentialTask(ctx context.Context, db orm.DB, instance *approval.Instance, ...) error
- func (*TaskService) BuildActionLog(instanceID string, task *approval.Task, operator approval.UserInfo, ...) *approval.ActionLog
- func (*TaskService) CanRemoveAssigneeTask(ctx context.Context, db orm.DB, eng *engine.FlowEngine, ...) (bool, error)
- func (s *TaskService) CancelInstanceTasks(ctx context.Context, db orm.DB, instance *approval.Instance, reason string) ([]approval.DomainEvent, error)
- func (s *TaskService) CancelRemainingTasks(ctx context.Context, db orm.DB, instance *approval.Instance, ...) ([]approval.DomainEvent, error)
- func (*TaskService) FinishTask(ctx context.Context, db orm.DB, task *approval.Task, ...) error
- func (*TaskService) IsAuthorizedForNodeOperation(ctx context.Context, db orm.DB, instanceID, nodeID, operatorID string) (bool, error)
- func (*TaskService) IsInstanceParticipant(ctx context.Context, db orm.DB, instanceID, userID string) (bool, error)
- func (*TaskService) IsUrgeAuthorized(ctx context.Context, db orm.DB, instanceID, userID string) (bool, error)
- func (s *TaskService) LoadTaskContextForNodeOperation(ctx context.Context, db orm.DB, taskID string, options TaskContextLoadOptions) (*TaskContext, error)
- func (*TaskService) PersistInstanceFormData(ctx context.Context, db orm.DB, instance *approval.Instance) error
- func (s *TaskService) PrepareOperation(ctx context.Context, db orm.DB, taskID string, operator approval.UserInfo, ...) (*TaskContext, error)
- func (*TaskService) RepointAddAssigneeChildren(ctx context.Context, db orm.DB, fromParentID, toParentID, instanceID string) error
- type ValidationService
- func (s *ValidationService) CheckInitiationPermission(ctx context.Context, db orm.DB, flowID, applicantID string, ...) (bool, error)
- func (*ValidationService) ValidateFormData(fields []approval.FormFieldDefinition, formData map[string]any) error
- func (*ValidationService) ValidateOpinion(node *approval.FlowNode, opinion string) error
- func (*ValidationService) ValidateRequiredPermissionFields(fields []approval.FormFieldDefinition, ...) error
- func (*ValidationService) ValidateRollbackTarget(ctx context.Context, db orm.DB, instance *approval.Instance, ...) error
Constants ¶
const FormDataMaxBytes = 64 * 1024
FormDataMaxBytes caps the JSON-encoded form payload an applicant or approver can submit. 64 KiB is generous for legitimate forms (typical approval payloads are < 4 KiB) while still rejecting blobs that would bloat the JSONB column or drive the runtime into OOM. Override at build time only if you intentionally accept larger payloads.
Variables ¶
var Module = fx.Module( "vef:approval:service", fx.Provide( fx.Annotate( func(aggregators []approval.Aggregator) *FlowDefinitionService { kinds := make([]approval.AggregateKind, 0, len(aggregators)) for _, aggregator := range aggregators { kinds = append(kinds, aggregator.Kind()) } return NewFlowDefinitionService(kinds...) }, fx.ParamTags(`group:"vef:approval:aggregators"`), ), NewTaskService, NewNodeService, NewValidationService, NewInstanceService, ), )
Module provides all approval services.
Functions ¶
func FilterEditableFormData ¶
func FilterEditableFormData(formData map[string]any, permissions map[string]approval.Permission) map[string]any
FilterEditableFormData filters form data to only include fields that are editable or required based on the node's field permissions configuration.
func MergeFormData ¶
func MergeFormData(instance *approval.Instance, formData map[string]any, permissions map[string]approval.Permission)
MergeFormData filters editable form data and merges it into the instance.
Types ¶
type ActionLogParams ¶
type ActionLogParams struct {
Opinion string
// TransferTo names the recipient of a transfer-style action, snapshotting
// identity and department at action time.
TransferTo *approval.UserInfo
RollbackToNodeID string
// Attachments holds storage file references the operator attached to this
// action (e.g. a signed document uploaded with an approval opinion). The
// approval module persists them verbatim; upload/resolution is the caller's
// concern (the storage module).
Attachments []string
}
ActionLogParams holds optional fields for BuildActionLog.
type FlowDefinitionService ¶
type FlowDefinitionService struct {
// contains filtered or unexported fields
}
FlowDefinitionService provides flow-level domain operations.
func NewFlowDefinitionService ¶
func NewFlowDefinitionService(aggregateKinds ...approval.AggregateKind) *FlowDefinitionService
NewFlowDefinitionService creates a new FlowDefinitionService. The registered aggregate kinds default to the built-ins when omitted (the production module passes the full boot-registered set).
func (*FlowDefinitionService) ValidateConditionAggregates ¶ added in v0.36.0
func (s *FlowDefinitionService) ValidateConditionAggregates(nodeData map[string]approval.NodeData, fields []approval.FormFieldDefinition) error
ValidateConditionAggregates cross-checks every aggregate field condition in the parsed node data against the version's parsed form fields: the subject must name a table field and, for column-folding aggregates, the column must exist in that table and be a number field. The structural per-condition rules (operator whitelist, column presence contract) live in validateCondition; this pass adds what only the form fields and the boot-registered aggregator set can answer, so it runs at deploy — the one place they all meet.
The column contract is derived from AggregateKind.FoldsColumn, so a new aggregate kind extends this validation without modifying it.
func (*FlowDefinitionService) ValidateFieldPermissions ¶ added in v0.38.0
func (*FlowDefinitionService) ValidateFieldPermissions(nodeData map[string]approval.NodeData, fields []approval.FormFieldDefinition) error
ValidateFieldPermissions cross-checks every node's FieldPermissions matrix against the version's parsed form fields: every value must be a defined Permission, every key must name a top-level form field (a flow with no form has zero fields, so any entry is automatically a dangling reference), and CC nodes are further restricted to visible/hidden — the same subset the designer's CcFieldPermission type offers, since a CC node only observes the form and can neither unlock editing nor demand a value.
The structural per-node checks (enum validity of every other node field) live in validateNodeConfig; this pass adds what only the form schema can answer, so — like ValidateConditionAggregates — it runs at deploy, the one place the flow definition and the form fields meet.
func (*FlowDefinitionService) ValidateFlowDefinition ¶
func (*FlowDefinitionService) ValidateFlowDefinition(def *approval.FlowDefinition) (map[string]approval.NodeData, error)
ValidateFlowDefinition validates a flow graph and returns the parsed node data (which deploy persists verbatim — it is never re-parsed). It runs four phases in sequence — node validation, edge/adjacency, degree constraints, topology — each extracted into a focused helper; later phases consume the scan state earlier ones produce.
func (*FlowDefinitionService) ValidateFormFields ¶ added in v0.38.0
func (*FlowDefinitionService) ValidateFormFields(fields []approval.FormFieldDefinition) error
ValidateFormFields validates the structural integrity of the parsed form fields at deploy time: unique non-empty field keys, known field kinds, compilable validation patterns, and coherent min/max bounds. Everything checked here would otherwise only fail when an applicant submits — and an uncompilable pattern would even be misreported to them as a data error — so a broken field list must be rejected before the version is created. A nil list (a flow without a form) is valid.
type InstanceService ¶ added in v0.24.0
type InstanceService struct {
// contains filtered or unexported fields
}
InstanceService is the command layer's write-side entry point for instance status transitions, wrapping engine.ApplyInstanceTransitionWithHooks (the single write-side primitive, which engine-internal paths call directly). Every status change must funnel through that primitive so that state-machine validation, the optimistic-lock UPDATE, and the host-registered lifecycle hooks fire together. Direct UPDATE statements against apv_instance.status are a bug.
func NewInstanceService ¶ added in v0.24.0
func NewInstanceService(hooks *engine.LifecycleHookRunner) *InstanceService
NewInstanceService creates a new InstanceService. hooks may be nil in test fixtures; production wiring always supplies the engine's LifecycleHookRunner so every status transition advances the business projection and invokes registered extensions inside the same tx as the status change.
func (*InstanceService) ApplyRollbackFormData ¶ added in v0.29.0
func (*InstanceService) ApplyRollbackFormData( ctx context.Context, db orm.DB, instance *approval.Instance, targetNodeID string, strategy approval.RollbackDataStrategy, ) error
ApplyRollbackFormData resolves the instance form data for a rollback to targetNodeID according to the node's RollbackDataStrategy, mutating instance.FormData in memory. The rollback handler persists the result in the same UPDATE / Transition that writes current_node_id.
- RollbackDataKeep: restore the form snapshot captured when the target node was entered, so the redo round resumes from that node's state.
- RollbackDataClear: wipe the form data so the flow restarts with a clean form. (nullzero on Instance.FormData persists the nil map as NULL.)
- default (including unset): leave the current form data untouched.
This exhaustively handles the RollbackDataStrategy enum so a configured strategy can never silently no-op.
func (*InstanceService) LoadForUpdate ¶ added in v0.24.0
func (*InstanceService) LoadForUpdate( ctx context.Context, db orm.DB, instanceID string, caller approval.CallerContext, ) (*approval.Instance, error)
LoadForUpdate loads an instance by ID with a row-level lock and asserts that the caller is authorized to act on it. Cross-tenant access surfaces as ErrInstanceNotFound — the same response shape as "no such instance" — so the API never reveals existence across tenants.
caller follows the fail-closed CallerContext contract: a zero value is denied, and only super-admin / system-internal callers (test fixtures use approval.SystemCaller) bypass tenant enforcement. Production resource paths always populate it; making the parameter mandatory means "any tenant-scoped load goes through this guard" is a compile-time invariant, not a code-review hope.
func (*InstanceService) Transition ¶ added in v0.24.0
func (s *InstanceService) Transition( ctx context.Context, db orm.DB, instance *approval.Instance, to approval.InstanceStatus, extraCols ...string, ) error
Transition validates the instance status transition through the state machine, applies it atomically with an optimistic-lock UPDATE, and runs the business projection plus lifecycle hooks for every transition (final-state logic in a hook checks to.IsFinal()).
extraCols lists additional columns the caller pre-populated on instance and wants persisted in the same UPDATE (e.g. "finished_at", "current_node_id", "form_data"). The status column is always included.
If a concurrent writer already advanced the status, the UPDATE matches zero rows and Transition returns ErrInvalidInstanceTransition with the in-memory status restored to the pre-call value.
type NodeService ¶
type NodeService struct {
// contains filtered or unexported fields
}
NodeService provides node-level domain operations.
func NewNodeService ¶
func NewNodeService( engine *engine.FlowEngine, bus event.Bus, taskSvc *TaskService, userResolver approval.UserInfoResolver, ccResolver *shared.CCRecipientResolver, ) *NodeService
NewNodeService creates a new NodeService.
func (*NodeService) AdvanceCCNodeIfAllRead ¶ added in v0.35.0
func (s *NodeService) AdvanceCCNodeIfAllRead(ctx context.Context, db orm.DB, instanceID string, records []approval.CCRecord) error
AdvanceCCNodeIfAllRead checks if all CC records for CC nodes are read and advances the flow.
func (*NodeService) HandleNodeCompletion ¶
func (s *NodeService) HandleNodeCompletion( ctx context.Context, db orm.DB, instance *approval.Instance, node *approval.FlowNode, ) ([]approval.DomainEvent, error)
HandleNodeCompletion evaluates the node's pass rule and settles the outcome. Both outcomes trigger completion-timing CC, cancel the remaining tasks, and conclude the open node visit; PassRulePassed then advances to the next node while PassRuleRejected finishes the instance as rejected. A pending result is a no-op. The returned events (task cancellations, plus the completion event on rejection) are handed to the caller's own event flow.
This method persists status transitions and engine-driven node changes in the caller's transaction while keeping the supplied instance in sync.
func (*NodeService) TriggerNodeCC ¶
func (s *NodeService) TriggerNodeCC(ctx context.Context, db orm.DB, instance *approval.Instance, node *approval.FlowNode, completionResult approval.PassRuleResult) error
TriggerNodeCC creates CC records when a node completes, based on CCTiming configuration.
type TaskContext ¶
type TaskContext struct {
Instance *approval.Instance
Task *approval.Task
Node *approval.FlowNode
// FormFields is the flow version's parsed form fields. PrepareOperation
// loads them (only when the node grants field permissions) so submitted-value
// validation and the approve/handle required-permission check share one
// query; nil for permission-less nodes and for contexts loaded directly via
// LoadTaskContextForNodeOperation.
FormFields []approval.FormFieldDefinition
}
TaskContext holds the validated context for task processing operations.
type TaskContextLoadOptions ¶
type TaskContextLoadOptions struct {
OperatorID string
RequireOperatorAssignee bool
RequireTaskPending bool
RequireCurrentNode bool
// Caller asserts the caller's tenant authority. The loader rejects
// cross-tenant access (mapped to ErrTaskNotFound so callers cannot
// probe existence across tenants); only super-admin / system-internal
// callers bypass, and a zero value is denied fail-closed — see
// approval.CallerContext for the trust model.
Caller approval.CallerContext
}
TaskContextLoadOptions controls validations when loading task operation context.
type TaskService ¶
type TaskService struct{}
TaskService provides task-level domain operations.
func (*TaskService) ActivateDependentTasks ¶ added in v0.29.0
func (s *TaskService) ActivateDependentTasks(ctx context.Context, db orm.DB, instance *approval.Instance, node *approval.FlowNode, finishedTask *approval.Task) error
ActivateDependentTasks activates whatever the completion of finishedTask unblocks on its node, so the node keeps making progress.
Sequential nodes advance their single sort-ordered queue; add-assignee splices its tasks into that queue at the anchor's position, so they are picked up like any other — no special handling is needed.
Parallel nodes have no implicit queue, so a task suspended or queued by add-assignee would otherwise never become actionable — that was the deadlock where a "before" add-assignee permanently stranded the original assignee on an all/ratio node. Their dependencies are resolved explicitly via the parent/child link instead:
- a "before" parent, suspended to Waiting while its pre-approvers act, is reactivated once all of its before-children finish;
- "after" children, queued as Waiting, are activated once the parent they were attached to finishes.
Transfer and rollback intentionally do not call this: a transfer replaces a task in place (the work is not done), and a rollback abandons the node.
func (*TaskService) ActivateNextSequentialTask ¶
func (*TaskService) ActivateNextSequentialTask(ctx context.Context, db orm.DB, instance *approval.Instance, node *approval.FlowNode) error
ActivateNextSequentialTask activates the next waiting task in a node's sort-ordered queue. It is a no-op while any task on the node is still Pending, which makes it idempotent and safe to call after any task finishes — or after a queued (Waiting) task is removed — without ever leaving two tasks active at once.
func (*TaskService) BuildActionLog ¶ added in v0.24.0
func (*TaskService) BuildActionLog( instanceID string, task *approval.Task, operator approval.UserInfo, action approval.ActionType, params ActionLogParams, ) *approval.ActionLog
BuildActionLog constructs a task-scoped ActionLog entry. Persistence is deferred to ActionLogBehavior; callers hand the result to the request- scoped ActionLogCollector instead of inserting directly so transactional log writes happen at one place.
func (*TaskService) CanRemoveAssigneeTask ¶
func (*TaskService) CanRemoveAssigneeTask(ctx context.Context, db orm.DB, eng *engine.FlowEngine, node *approval.FlowNode, task approval.Task) (bool, error)
CanRemoveAssigneeTask determines whether removing a task can still drive the node to progress (either through remaining actionable tasks or immediate completion under pass-rule evaluation).
func (*TaskService) CancelInstanceTasks ¶
func (s *TaskService) CancelInstanceTasks(ctx context.Context, db orm.DB, instance *approval.Instance, reason string) ([]approval.DomainEvent, error)
CancelInstanceTasks cancels all pending/waiting tasks for an entire instance, returning the corresponding TaskCanceledEvents.
func (*TaskService) CancelRemainingTasks ¶
func (s *TaskService) CancelRemainingTasks(ctx context.Context, db orm.DB, instance *approval.Instance, node *approval.FlowNode, reason string) ([]approval.DomainEvent, error)
CancelRemainingTasks cancels all pending/waiting tasks on the given node and returns one TaskCanceledEvent per canceled task, so assignees whose decision is no longer needed can have their pending to-dos retracted. Callers attach the events to their own event flow (the caller holds the instance row lock, which serializes this two-step read-then-update against every other task mutation path).
func (*TaskService) FinishTask ¶
func (*TaskService) FinishTask(ctx context.Context, db orm.DB, task *approval.Task, status approval.TaskStatus) error
FinishTask transitions a task to the given status and sets its FinishedAt timestamp.
func (*TaskService) IsAuthorizedForNodeOperation ¶
func (*TaskService) IsAuthorizedForNodeOperation(ctx context.Context, db orm.DB, instanceID, nodeID, operatorID string) (bool, error)
IsAuthorizedForNodeOperation reports whether the operator may perform node-level operations (e.g. remove assignee): true if the operator is a peer assignee on the same node or a flow admin. Database errors are returned to the caller rather than swallowed, so an infrastructure failure surfaces as a server error instead of a silent authorization denial.
func (*TaskService) IsInstanceParticipant ¶
func (*TaskService) IsInstanceParticipant(ctx context.Context, db orm.DB, instanceID, userID string) (bool, error)
IsInstanceParticipant checks whether the user is related to the instance as applicant, task assignee, or CC recipient.
func (*TaskService) IsUrgeAuthorized ¶ added in v0.24.0
func (*TaskService) IsUrgeAuthorized(ctx context.Context, db orm.DB, instanceID, userID string) (bool, error)
IsUrgeAuthorized reports whether userID may dispatch an urge for tasks belonging to instanceID. Narrower than IsInstanceParticipant: only the applicant and users who have (or had) an assignee task on the instance count; CC recipients are excluded because they are not on the hook for the decision and the right to urge has been abused by random observers in prior incidents.
func (*TaskService) LoadTaskContextForNodeOperation ¶
func (s *TaskService) LoadTaskContextForNodeOperation(ctx context.Context, db orm.DB, taskID string, options TaskContextLoadOptions) (*TaskContext, error)
LoadTaskContextForNodeOperation loads and validates instance/task/node context for node operations. The lock order is always instance first, then task.
func (*TaskService) PersistInstanceFormData ¶ added in v0.29.0
func (*TaskService) PersistInstanceFormData(ctx context.Context, db orm.DB, instance *approval.Instance) error
PersistInstanceFormData writes back only the instance's form_data column. Task-action handlers (approve / reject / transfer) mutate form_data locally via MergeFormData while the state machine already persists status / current_node_id / finished_at, so this is the single column those handlers still need to flush — shared here so the verbatim UPDATE is not copy-pasted.
func (*TaskService) PrepareOperation ¶
func (s *TaskService) PrepareOperation(ctx context.Context, db orm.DB, taskID string, operator approval.UserInfo, caller approval.CallerContext, formData map[string]any) (*TaskContext, error)
PrepareOperation loads task context and merges editable form data. Callers that require opinion validation should invoke ValidateOpinion separately.
func (*TaskService) RepointAddAssigneeChildren ¶ added in v0.29.0
func (*TaskService) RepointAddAssigneeChildren(ctx context.Context, db orm.DB, fromParentID, toParentID, instanceID string) error
RepointAddAssigneeChildren re-parents the still-active add-assignee children of a replaced task onto its stand-in. A transfer finishes the original task and inserts a replacement with a new ID; the parallel-node dependency resolution keys off parent_task_id, so without re-pointing, the "after" children queued against the original would never be activated by the replacement's completion — orphaning them and re-creating the very deadlock the parent/child activation was built to avoid. Scoped to the instance for defense-in-depth on top of the globally-unique parent id.
type ValidationService ¶
type ValidationService struct {
// contains filtered or unexported fields
}
ValidationService provides validation operations.
func NewValidationService ¶
func NewValidationService(assigneeSvc approval.AssigneeService) *ValidationService
NewValidationService creates a new ValidationService.
func (*ValidationService) CheckInitiationPermission ¶
func (s *ValidationService) CheckInitiationPermission(ctx context.Context, db orm.DB, flowID, applicantID string, applicantDepartmentID *string) (bool, error)
CheckInitiationPermission checks if the applicant is allowed to initiate the flow.
func (*ValidationService) ValidateFormData ¶
func (*ValidationService) ValidateFormData(fields []approval.FormFieldDefinition, formData map[string]any) error
ValidateFormData validates submitted form data against the version's parsed form fields (nil = a flow without a form).
func (*ValidationService) ValidateOpinion ¶
func (*ValidationService) ValidateOpinion(node *approval.FlowNode, opinion string) error
ValidateOpinion checks if an opinion is required but missing.
func (*ValidationService) ValidateRequiredPermissionFields ¶ added in v0.38.0
func (*ValidationService) ValidateRequiredPermissionFields(fields []approval.FormFieldDefinition, permissions map[string]approval.Permission, formData map[string]any) error
ValidateRequiredPermissionFields ensures every field the node marks PermissionRequired holds a non-empty value by the time the task is approved/handled — the decision-completing actions. Reject, transfer and rollback stay exempt: a rejection must never be blocked by an unfilled field, and transfer/rollback hand the obligation to the next actor.
It checks the instance's merged form data, so a value filled by an earlier participant satisfies the requirement — the semantic is "filled by the time this node passes", not "resubmitted at this node". Permission keys with no matching schema field are ignored; deploy validation owns that pairing.
func (*ValidationService) ValidateRollbackTarget ¶
func (*ValidationService) ValidateRollbackTarget(ctx context.Context, db orm.DB, instance *approval.Instance, currentNode *approval.FlowNode, targetNodeID string) error
ValidateRollbackTarget validates the rollback target node based on the node's RollbackType.