ahgWorkflowPlugin - Technical Documentation¶
Version: 1.0.0 Category: Workflow Management Dependencies: atom-framework, ahgSecurityClearancePlugin
Overview¶
Configurable approval workflow system for archival submissions, inspired by DSpace's workflow architecture. Supports multi-step review processes, role-based task pools, email notifications, and comprehensive audit trails.
Architecture¶
┌─────────────────────────────────────────────────────────────────────┐
│ ahgWorkflowPlugin │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Workflow Definition │ │
│ │ │ │
│ │ ahg_workflow ─────────▶ ahg_workflow_step │ │
│ │ (workflow config) (ordered steps) │ │
│ │ │ │ │
│ │ │ role_id │ │
│ │ ▼ │ │
│ │ acl_permission (AtoM roles) │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Task Management │ │
│ │ │ │
│ │ ahg_workflow_task ◀────────────────────────────────────────│ │
│ │ │ object_id (information_object) │ │
│ │ │ workflow_id │ │
│ │ │ current_step │ │
│ │ │ claimed_by (user_id) │ │
│ │ │ status (pending/claimed/in_progress/approved/rejected) │ │
│ │ │ │ │
│ │ └──────▶ ahg_workflow_history │ │
│ │ (audit trail of all actions) │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Notifications │ │
│ │ │ │
│ │ ahg_workflow_notification │ │
│ │ │ task_id │ │
│ │ │ user_id │ │
│ │ │ notification_type │ │
│ │ │ status (pending/sent/failed) │ │
│ │ │ │ │
│ │ └──────▶ Email Queue ──────▶ SMTP │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Database Schema¶
ERD Diagram¶
┌─────────────────────────────────────────┐
│ ahg_workflow │
├─────────────────────────────────────────┤
│ PK id BIGINT UNSIGNED AUTO_INCREMENT │
│ name VARCHAR(255) NOT NULL │
│ description TEXT │
│ is_active TINYINT(1) DEFAULT 1 │
│ FK repository_id INT NULL │
│ scope ENUM('global','repository') │
│ created_at TIMESTAMP │
│ updated_at TIMESTAMP │
├─────────────────────────────────────────┤
│ IDX idx_workflow_active (is_active) │
│ IDX idx_workflow_repo (repository_id) │
└───────────────────┬─────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────┐
│ ahg_workflow_step │
├─────────────────────────────────────────┤
│ PK id BIGINT UNSIGNED AUTO_INCREMENT │
│ FK workflow_id BIGINT UNSIGNED NOT NULL │
│ name VARCHAR(255) NOT NULL │
│ step_order INT NOT NULL │
│ FK role_id INT NOT NULL │
│ instructions TEXT │
│ due_days INT DEFAULT 7 │
│ allow_reject TINYINT(1) DEFAULT 1 │
│ allow_return TINYINT(1) DEFAULT 1 │
│ created_at TIMESTAMP │
├─────────────────────────────────────────┤
│ UNIQUE KEY (workflow_id, step_order) │
│ FK REFERENCES ahg_workflow(id) CASCADE │
└───────────────────┬─────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────┐
│ ahg_workflow_task │
├─────────────────────────────────────────┤
│ PK id BIGINT UNSIGNED AUTO_INCREMENT │
│ FK workflow_id BIGINT UNSIGNED NOT NULL │
│ FK object_id INT NOT NULL │
│ object_type VARCHAR(100) DEFAULT │
│ 'information_object' │
│ current_step INT NOT NULL DEFAULT 1 │
│ status ENUM('pending','claimed', │
│ 'in_progress','approved', │
│ 'rejected','returned') │
│ FK submitted_by INT NOT NULL │
│ FK claimed_by INT NULL │
│ claimed_at TIMESTAMP NULL │
│ due_date DATE NULL │
│ priority INT DEFAULT 0 │
│ notes TEXT │
│ created_at TIMESTAMP │
│ updated_at TIMESTAMP │
├─────────────────────────────────────────┤
│ IDX idx_task_status (status) │
│ IDX idx_task_object (object_id) │
│ IDX idx_task_claimed (claimed_by) │
│ IDX idx_task_due (due_date) │
│ FK REFERENCES ahg_workflow(id) │
│ FK REFERENCES user(id) │
└───────────────────┬─────────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────────────┐
│ ahg_workflow_history │
├─────────────────────────────────────────┤
│ PK id BIGINT UNSIGNED AUTO_INCREMENT │
│ FK task_id BIGINT UNSIGNED NOT NULL │
│ action ENUM('submitted','claimed', │
│ 'released','approved', │
│ 'rejected','returned', │
│ 'escalated','advanced', │
│ 'completed') │
│ from_step INT NULL │
│ to_step INT NULL │
│ FK performed_by INT NOT NULL │
│ comments TEXT │
│ ip_address VARCHAR(45) │
│ created_at TIMESTAMP │
├─────────────────────────────────────────┤
│ IDX idx_history_task (task_id) │
│ FK REFERENCES ahg_workflow_task(id) │
│ FK REFERENCES user(id) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ ahg_workflow_notification │
├─────────────────────────────────────────┤
│ PK id BIGINT UNSIGNED AUTO_INCREMENT │
│ FK task_id BIGINT UNSIGNED NOT NULL │
│ FK user_id INT NOT NULL │
│ notification_type ENUM('new_task', │
│ 'task_claimed','approved', │
│ 'rejected','returned', │
│ 'reminder','escalation') │
│ status ENUM('pending','sent', │
│ 'failed') DEFAULT 'pending' │
│ sent_at TIMESTAMP NULL │
│ error_message TEXT │
│ created_at TIMESTAMP │
├─────────────────────────────────────────┤
│ IDX idx_notif_status (status) │
│ IDX idx_notif_user (user_id) │
│ FK REFERENCES ahg_workflow_task(id) │
└─────────────────────────────────────────┘
Service Layer¶
WorkflowService¶
Location: lib/Services/WorkflowService.php
namespace AtomExtensions\Workflow;
use Illuminate\Database\Capsule\Manager as DB;
class WorkflowService
{
/**
* Get applicable workflow for an object
*
* @param int $objectId Information object ID
* @return object|null Workflow configuration
*/
public function getWorkflowForObject(int $objectId): ?object;
/**
* Submit object to workflow
*
* @param int $objectId Information object ID
* @param int $userId Submitting user ID
* @param int|null $workflowId Specific workflow (null = auto-detect)
* @return object Created task
*/
public function submitToWorkflow(
int $objectId,
int $userId,
?int $workflowId = null
): object;
/**
* Get available tasks for user's role
*
* @param int $userId User ID
* @param array $filters Optional filters
* @return array Tasks in pool
*/
public function getAvailableTasks(int $userId, array $filters = []): array;
/**
* Claim a task
*
* @param int $taskId Task ID
* @param int $userId User ID
* @return bool Success
*/
public function claimTask(int $taskId, int $userId): bool;
/**
* Release a claimed task back to pool
*
* @param int $taskId Task ID
* @param int $userId User ID
* @return bool Success
*/
public function releaseTask(int $taskId, int $userId): bool;
/**
* Approve task (advance to next step)
*
* @param int $taskId Task ID
* @param int $userId User ID
* @param string|null $comments Optional comments
* @return bool Success
*/
public function approveTask(
int $taskId,
int $userId,
?string $comments = null
): bool;
/**
* Reject task (end workflow)
*
* @param int $taskId Task ID
* @param int $userId User ID
* @param string $reason Rejection reason (required)
* @return bool Success
*/
public function rejectTask(
int $taskId,
int $userId,
string $reason
): bool;
/**
* Return task to submitter for revision
*
* @param int $taskId Task ID
* @param int $userId User ID
* @param string $reason Return reason (required)
* @return bool Success
*/
public function returnTask(
int $taskId,
int $userId,
string $reason
): bool;
/**
* Get workflow statistics
*
* @return array Statistics summary
*/
public function getStatistics(): array;
/**
* Process pending notifications
*
* @param int $limit Maximum to process
* @return array Results [sent, failed]
*/
public function processNotifications(int $limit = 100): array;
/**
* Escalate overdue tasks
*
* @return int Number escalated
*/
public function escalateOverdueTasks(): int;
}
Task State Machine¶
┌─────────────┐
│ SUBMITTED │
└──────┬──────┘
│
▼
┌─────────────┐
┌─────────│ PENDING │◀────────┐
│ └──────┬──────┘ │
│ │ │
│ │ claim │ release
│ ▼ │
│ ┌─────────────┐ │
│ │ CLAIMED │─────────┘
│ └──────┬──────┘
│ │
│ │ start work
│ ▼
│ ┌─────────────┐
│ │ IN_PROGRESS │
│ └──────┬──────┘
│ │
┌───────┼────────┬───────┴───────┐
│ │ │ │
▼ │ ▼ ▼
┌──────────┐ │ ┌──────────┐ ┌──────────┐
│ APPROVED │ │ │ REJECTED │ │ RETURNED │
└────┬─────┘ │ └──────────┘ └─────┬────┘
│ │ │
│ │ │ resubmit
│ └───────────────────────┘
│
│ more steps?
│
┌─────┴─────┐
│ │
│ YES │ NO
▼ ▼
┌─────────┐ ┌──────────┐
│ PENDING │ │ COMPLETED│
│(next) │ │(publish) │
└─────────┘ └──────────┘
Configuration Class¶
Location: config/ahgWorkflowPluginConfiguration.class.php
class ahgWorkflowPluginConfiguration extends sfPluginConfiguration
{
public function initialize()
{
// Register routes
$this->dispatcher->connect(
'routing.load_configuration',
[$this, 'addRoutes']
);
}
public function addRoutes(sfEvent $event)
{
$routing = $event->getSubject();
// Dashboard
$routing->prependRoute('workflow_dashboard',
new sfRoute('/workflow',
['module' => 'workflow', 'action' => 'dashboard']));
// Task pool
$routing->prependRoute('workflow_pool',
new sfRoute('/workflow/pool',
['module' => 'workflow', 'action' => 'pool']));
// My tasks
$routing->prependRoute('workflow_my_tasks',
new sfRoute('/workflow/my-tasks',
['module' => 'workflow', 'action' => 'myTasks']));
// Task actions
$routing->prependRoute('workflow_task',
new sfRoute('/workflow/task/:id',
['module' => 'workflow', 'action' => 'task']));
$routing->prependRoute('workflow_claim',
new sfRoute('/workflow/claim/:id',
['module' => 'workflow', 'action' => 'claim']));
$routing->prependRoute('workflow_review',
new sfRoute('/workflow/review/:id',
['module' => 'workflow', 'action' => 'review']));
// Admin
$routing->prependRoute('workflow_admin',
new sfRoute('/workflow/admin',
['module' => 'workflow', 'action' => 'admin']));
$routing->prependRoute('workflow_edit',
new sfRoute('/workflow/admin/edit/:id',
['module' => 'workflow', 'action' => 'edit']));
$routing->prependRoute('workflow_steps',
new sfRoute('/workflow/admin/steps/:id',
['module' => 'workflow', 'action' => 'steps']));
}
}
CLI Tasks¶
workflow:process¶
Location: lib/task/workflowProcessTask.class.php
# Process all workflow operations
php symfony workflow:process
# Options
--notifications # Only process pending notifications
--escalate # Only escalate overdue tasks
--cleanup # Clean up old completed tasks
--days=N # Days to retain completed tasks (default: 90)
--limit=N # Max items to process (default: 100)
workflow:status¶
Location: lib/task/workflowStatusTask.class.php
# Show workflow status summary
php symfony workflow:status
# Options
--pending # Show only pending tasks
--overdue # Show only overdue tasks
--format=FORMAT # Output format: table, json, csv
Module Structure¶
modules/
└── workflow/
├── actions/
│ └── actions.class.php
│ ├── executeDashboard() # Main dashboard
│ ├── executePool() # Task pool view
│ ├── executeMyTasks() # User's claimed tasks
│ ├── executeTask() # Single task view
│ ├── executeClaim() # Claim task action
│ ├── executeRelease() # Release task action
│ ├── executeReview() # Review/decision form
│ ├── executeApprove() # Approve action
│ ├── executeReject() # Reject action
│ ├── executeReturn() # Return action
│ ├── executeAdmin() # Admin dashboard
│ ├── executeEdit() # Edit workflow
│ └── executeSteps() # Manage steps
│
└── templates/
├── dashboardSuccess.php # Main dashboard
├── poolSuccess.php # Task pool
├── myTasksSuccess.php # My tasks
├── taskSuccess.php # Task detail
├── reviewSuccess.php # Review form
├── adminSuccess.php # Admin list
├── editSuccess.php # Edit workflow
├── stepsSuccess.php # Manage steps
└── historySuccess.php # Task history
Security Integration¶
Role-Based Access¶
The plugin integrates with ahgSecurityClearancePlugin for role-based task assignment:
// Get users with role matching step requirement
$step = DB::table('ahg_workflow_step')
->where('id', $stepId)
->first();
$usersWithRole = DB::table('acl_permission')
->join('user', 'user.id', '=', 'acl_permission.user_id')
->where('acl_permission.group_id', $step->role_id)
->where('user.active', 1)
->get();
Permission Checks¶
// Check if user can claim task
public function canUserClaimTask(int $userId, int $taskId): bool
{
$task = $this->getTask($taskId);
$step = $this->getStep($task->workflow_id, $task->current_step);
// Check user has required role
$hasRole = DB::table('acl_permission')
->where('user_id', $userId)
->where('group_id', $step->role_id)
->exists();
return $hasRole && $task->status === 'pending';
}
Email Notifications¶
Notification Types¶
| Type | Trigger | Recipients |
|---|---|---|
new_task |
Task enters pool | All users with step's role |
task_claimed |
Task claimed | Claimer (confirmation) |
approved |
Task approved | Original submitter |
rejected |
Task rejected | Original submitter |
returned |
Task returned | Original submitter |
reminder |
Task approaching due | Task owner |
escalation |
Task overdue | Supervisor + Admin |
Email Template Structure¶
// Email templates in lib/email/
WorkflowEmailService::class
├── sendNewTaskNotification()
├── sendClaimConfirmation()
├── sendApprovalNotification()
├── sendRejectionNotification()
├── sendReturnNotification()
├── sendReminderNotification()
└── sendEscalationNotification()
API Endpoints¶
REST Endpoints (if ahgAPIPlugin enabled)¶
GET /api/workflow/tasks # List available tasks
GET /api/workflow/tasks/:id # Get task details
POST /api/workflow/tasks/:id/claim # Claim task
POST /api/workflow/tasks/:id/release # Release task
POST /api/workflow/tasks/:id/approve # Approve task
POST /api/workflow/tasks/:id/reject # Reject task
POST /api/workflow/tasks/:id/return # Return task
GET /api/workflow/my-tasks # Get user's tasks
GET /api/workflow/statistics # Get statistics
Events¶
Dispatched Events¶
// Event: workflow.task.submitted
$this->dispatcher->notify(new sfEvent($this, 'workflow.task.submitted', [
'task_id' => $taskId,
'object_id' => $objectId,
'user_id' => $userId,
]));
// Event: workflow.task.claimed
$this->dispatcher->notify(new sfEvent($this, 'workflow.task.claimed', [
'task_id' => $taskId,
'user_id' => $userId,
]));
// Event: workflow.task.approved
$this->dispatcher->notify(new sfEvent($this, 'workflow.task.approved', [
'task_id' => $taskId,
'user_id' => $userId,
'final' => $isFinalStep,
]));
// Event: workflow.task.rejected
// Event: workflow.task.returned
// Event: workflow.task.completed
Installation¶
Database Migration¶
Enable Plugin¶
Configure Cron¶
Default Workflow¶
The installation creates a default "Standard Review Workflow":
-- Default workflow
INSERT INTO ahg_workflow (name, description, scope, is_active)
VALUES ('Standard Review Workflow',
'Two-step review process with editor review and admin approval',
'global', 1);
-- Step 1: Editor Review
INSERT INTO ahg_workflow_step (workflow_id, name, step_order, role_id, due_days)
VALUES (1, 'Initial Review', 1, 4, 7); -- role 4 = Editor
-- Step 2: Admin Approval
INSERT INTO ahg_workflow_step (workflow_id, name, step_order, role_id, due_days)
VALUES (1, 'Final Approval', 2, 3, 3); -- role 3 = Administrator
Performance Considerations¶
Indexes¶
-- Task queries
CREATE INDEX idx_task_status ON ahg_workflow_task(status);
CREATE INDEX idx_task_claimed ON ahg_workflow_task(claimed_by);
CREATE INDEX idx_task_due ON ahg_workflow_task(due_date);
CREATE INDEX idx_task_object ON ahg_workflow_task(object_id, object_type);
-- History queries
CREATE INDEX idx_history_task ON ahg_workflow_history(task_id);
CREATE INDEX idx_history_date ON ahg_workflow_history(created_at);
-- Notification processing
CREATE INDEX idx_notif_status ON ahg_workflow_notification(status);
Query Optimization¶
// Efficient pool query with role check
$tasks = DB::table('ahg_workflow_task as t')
->join('ahg_workflow_step as s', function($join) {
$join->on('s.workflow_id', '=', 't.workflow_id')
->on('s.step_order', '=', 't.current_step');
})
->join('acl_permission as p', 'p.group_id', '=', 's.role_id')
->where('t.status', 'pending')
->where('p.user_id', $userId)
->select('t.*')
->distinct()
->get();
Troubleshooting¶
| Issue | Cause | Solution |
|---|---|---|
| Tasks not appearing | Role mismatch | Verify user has step's required role |
| Notifications not sending | SMTP not configured | Configure AtoM SMTP settings |
| Escalation not working | Cron not running | Enable workflow:process cron job |
| Can't claim task | Already claimed | Task was claimed by another user |
| Workflow not found | Repository mismatch | Check workflow scope settings |