diff --git a/Modules/Blog/Models/Blog.php b/Modules/Blog/Models/Blog.php index 754009f..a06ef09 100644 --- a/Modules/Blog/Models/Blog.php +++ b/Modules/Blog/Models/Blog.php @@ -1,4 +1,4 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Workflow\Concerns\HasWorkflow; +final class Blog extends Model { use HasWorkflow; protected $table='blog_posts'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/News/Models/News.php b/Modules/News/Models/News.php index 57b32d4..5cd07cb 100644 --- a/Modules/News/Models/News.php +++ b/Modules/News/Models/News.php @@ -1,4 +1,4 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Workflow\Concerns\HasWorkflow; +final class News extends Model { use HasWorkflow; protected $table='news_articles'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/Pages/Models/Page.php b/Modules/Pages/Models/Page.php index 2a713a1..3782be2 100644 --- a/Modules/Pages/Models/Page.php +++ b/Modules/Pages/Models/Page.php @@ -5,10 +5,11 @@ namespace Modules\Pages\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Modules\Workflow\Concerns\HasWorkflow; final class Page extends Model { - use HasFactory, SoftDeletes; + use HasFactory, HasWorkflow, SoftDeletes; protected $table = 'pages'; protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home']; diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 2c0140e..2448a59 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -50,6 +50,9 @@ final class RolePermissionSeeder extends Seeder 'events.manage', 'careers.manage', 'locations.manage', + 'workflow.view', + 'workflow.manage', + 'workflow.review', ]; foreach ($permissions as $permission) { diff --git a/Modules/Workflow/Concerns/HasWorkflow.php b/Modules/Workflow/Concerns/HasWorkflow.php new file mode 100644 index 0000000..34ab708 --- /dev/null +++ b/Modules/Workflow/Concerns/HasWorkflow.php @@ -0,0 +1,4 @@ +morphOne(WorkflowInstance::class,'subject'); } public function workflowLabel(): string { return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey()); } } diff --git a/Modules/Workflow/Config/config.php b/Modules/Workflow/Config/config.php new file mode 100644 index 0000000..33f0c2c --- /dev/null +++ b/Modules/Workflow/Config/config.php @@ -0,0 +1,2 @@ + 'Workflow', 'default_due_days' => 3]; diff --git a/Modules/Workflow/Contracts/Workflowable.php b/Modules/Workflow/Contracts/Workflowable.php new file mode 100644 index 0000000..5661475 --- /dev/null +++ b/Modules/Workflow/Contracts/Workflowable.php @@ -0,0 +1,4 @@ +id(); $t->string('name'); $t->string('slug')->unique(); $t->text('description')->nullable(); $t->string('subject_type')->nullable()->index(); $t->boolean('is_active')->default(true); $t->timestamps(); }); + Schema::create('workflow_stages', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); $t->string('name'); $t->string('slug'); $t->unsignedInteger('position')->default(0); $t->string('required_permission')->nullable(); $t->boolean('is_initial')->default(false); $t->boolean('is_terminal')->default(false); $t->boolean('allows_rejection')->default(true); $t->unsignedInteger('due_days')->nullable(); $t->timestamps(); $t->unique(['workflow_definition_id','slug']); }); + Schema::create('workflow_transitions', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); $t->foreignId('from_stage_id')->constrained('workflow_stages')->cascadeOnDelete(); $t->foreignId('to_stage_id')->constrained('workflow_stages')->cascadeOnDelete(); $t->string('name'); $t->string('action'); $t->string('required_permission')->nullable(); $t->boolean('requires_comment')->default(false); $t->timestamps(); }); + Schema::create('workflow_instances', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); $t->foreignId('workflow_stage_id')->constrained('workflow_stages'); $t->string('subject_type'); $t->unsignedBigInteger('subject_id'); $t->string('status')->default('active'); $t->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete(); $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); $t->timestamp('due_at')->nullable(); $t->timestamp('completed_at')->nullable(); $t->timestamps(); $t->unique(['subject_type','subject_id']); $t->index(['status','workflow_stage_id']); }); + Schema::create('workflow_history', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_instance_id')->constrained()->cascadeOnDelete(); $t->foreignId('from_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete(); $t->foreignId('to_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete(); $t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); $t->string('action'); $t->text('comment')->nullable(); $t->json('metadata')->nullable(); $t->timestamps(); }); + } + public function down(): void { Schema::dropIfExists('workflow_history'); Schema::dropIfExists('workflow_instances'); Schema::dropIfExists('workflow_transitions'); Schema::dropIfExists('workflow_stages'); Schema::dropIfExists('workflow_definitions'); } +}; diff --git a/Modules/Workflow/Http/Controllers/WorkflowController.php b/Modules/Workflow/Http/Controllers/WorkflowController.php new file mode 100644 index 0000000..cface5c --- /dev/null +++ b/Modules/Workflow/Http/Controllers/WorkflowController.php @@ -0,0 +1,8 @@ +user()->can('workflow.view'),403); return view('workflow::admin.index',['definitions'=>WorkflowDefinition::with('stages')->latest()->get(),'queue'=>WorkflowInstance::with(['definition','stage','subject'])->where('status','active')->latest()->paginate(20),'completed'=>WorkflowInstance::where('status','completed')->count()]); } + public function store(Request $request): RedirectResponse { abort_unless($request->user()->can('workflow.manage'),403); $data=$request->validate(['name'=>'required|string|max:120','description'=>'nullable|string','subject_type'=>'nullable|string|max:255']); $definition=WorkflowDefinition::create($data+['slug'=>Str::slug($data['name']),'is_active'=>true]); $draft=$definition->stages()->create(['name'=>'Draft','slug'=>'draft','position'=>0,'is_initial'=>true,'due_days'=>null]); $review=$definition->stages()->create(['name'=>'Review','slug'=>'review','position'=>10,'required_permission'=>'workflow.review','due_days'=>3]); $published=$definition->stages()->create(['name'=>'Approved','slug'=>'approved','position'=>20,'is_terminal'=>true,'allows_rejection'=>false]); $definition->transitions()->createMany([['from_stage_id'=>$draft->id,'to_stage_id'=>$review->id,'name'=>'Submit for review','action'=>'submit'],['from_stage_id'=>$review->id,'to_stage_id'=>$published->id,'name'=>'Approve','action'=>'approve','required_permission'=>'workflow.review'],['from_stage_id'=>$review->id,'to_stage_id'=>$draft->id,'name'=>'Request changes','action'=>'reject','required_permission'=>'workflow.review','requires_comment'=>true]]); return back()->with('success','Workflow created.'); } + public function transition(Request $request, WorkflowInstance $instance, WorkflowManager $manager): RedirectResponse { abort_unless($request->user()->can('workflow.review'),403); $data=$request->validate(['action'=>'required|string','comment'=>'nullable|string|max:2000']); $manager->transition($instance,$data['action'],$request->user(),$data['comment']??null); return back()->with('success','Workflow updated.'); } +} diff --git a/Modules/Workflow/Models/WorkflowDefinition.php b/Modules/Workflow/Models/WorkflowDefinition.php new file mode 100644 index 0000000..d7140ee --- /dev/null +++ b/Modules/Workflow/Models/WorkflowDefinition.php @@ -0,0 +1,4 @@ +'boolean']; } public function stages(): HasMany { return $this->hasMany(WorkflowStage::class)->orderBy('position'); } public function transitions(): HasMany { return $this->hasMany(WorkflowTransition::class); } } diff --git a/Modules/Workflow/Models/WorkflowHistory.php b/Modules/Workflow/Models/WorkflowHistory.php new file mode 100644 index 0000000..15f3fae --- /dev/null +++ b/Modules/Workflow/Models/WorkflowHistory.php @@ -0,0 +1,4 @@ +'array']; } } diff --git a/Modules/Workflow/Models/WorkflowInstance.php b/Modules/Workflow/Models/WorkflowInstance.php new file mode 100644 index 0000000..2d05a36 --- /dev/null +++ b/Modules/Workflow/Models/WorkflowInstance.php @@ -0,0 +1,4 @@ +'datetime','completed_at'=>'datetime']; } public function definition(): BelongsTo { return $this->belongsTo(WorkflowDefinition::class,'workflow_definition_id'); } public function stage(): BelongsTo { return $this->belongsTo(WorkflowStage::class,'workflow_stage_id'); } public function subject(): MorphTo { return $this->morphTo(); } public function history(): HasMany { return $this->hasMany(WorkflowHistory::class)->latest(); } } diff --git a/Modules/Workflow/Models/WorkflowStage.php b/Modules/Workflow/Models/WorkflowStage.php new file mode 100644 index 0000000..530c564 --- /dev/null +++ b/Modules/Workflow/Models/WorkflowStage.php @@ -0,0 +1,4 @@ +'boolean','is_terminal'=>'boolean','allows_rejection'=>'boolean']; } public function definition(): BelongsTo { return $this->belongsTo(WorkflowDefinition::class,'workflow_definition_id'); } } diff --git a/Modules/Workflow/Models/WorkflowTransition.php b/Modules/Workflow/Models/WorkflowTransition.php new file mode 100644 index 0000000..dc0cc37 --- /dev/null +++ b/Modules/Workflow/Models/WorkflowTransition.php @@ -0,0 +1,4 @@ +'boolean']; } } diff --git a/Modules/Workflow/Providers/RouteServiceProvider.php b/Modules/Workflow/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..b8c4fcf --- /dev/null +++ b/Modules/Workflow/Providers/RouteServiceProvider.php @@ -0,0 +1,5 @@ +group(module_path('Workflow', 'Routes/web.php')); } } diff --git a/Modules/Workflow/Providers/WorkflowServiceProvider.php b/Modules/Workflow/Providers/WorkflowServiceProvider.php new file mode 100644 index 0000000..b6d0f2c --- /dev/null +++ b/Modules/Workflow/Providers/WorkflowServiceProvider.php @@ -0,0 +1,8 @@ +mergeConfigFrom(module_path('Workflow','Config/config.php'),'workflow'); $this->app->singleton(WorkflowManager::class); $this->app->register(RouteServiceProvider::class); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Workflow','Database/Migrations')); $this->loadViewsFrom(module_path('Workflow','Resources/views'),'workflow'); } +} diff --git a/Modules/Workflow/Resources/views/admin/index.blade.php b/Modules/Workflow/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..673cde3 --- /dev/null +++ b/Modules/Workflow/Resources/views/admin/index.blade.php @@ -0,0 +1 @@ +
WorkflowApproval definitions, active reviews and completed work.
Definitions{{ $definitions->count() }}
Awaiting action{{ $queue->total() }}
Completed{{ $completed }}
Review queue
@forelse($queue as $item)
{{ method_exists($item->subject,'workflowLabel') ? $item->subject->workflowLabel() : class_basename($item->subject_type).' #'.$item->subject_id }}
{{ $item->definition->name }} · {{ $item->stage->name }} @if($item->due_at) · due {{ $item->due_at->diffForHumans() }} @endif
@csrfApprove
@csrfChanges
@emptyNo items are awaiting review.@endforelse
{{ $queue->links() }}
Create workflow
@csrfCreate
@foreach($definitions as $definition)
{{ $definition->name }}
{{ $definition->stages->pluck('name')->join(' → ') }}
@endforeach
diff --git a/Modules/Workflow/Routes/web.php b/Modules/Workflow/Routes/web.php new file mode 100644 index 0000000..24773fd --- /dev/null +++ b/Modules/Workflow/Routes/web.php @@ -0,0 +1,3 @@ +prefix('administration/workflows')->name('administration.workflows.')->group(function(){ Route::get('/',[WorkflowController::class,'index'])->name('index'); Route::post('/',[WorkflowController::class,'store'])->name('store'); Route::post('/{instance}/transition',[WorkflowController::class,'transition'])->name('transition'); }); diff --git a/Modules/Workflow/Services/WorkflowManager.php b/Modules/Workflow/Services/WorkflowManager.php new file mode 100644 index 0000000..4033643 --- /dev/null +++ b/Modules/Workflow/Services/WorkflowManager.php @@ -0,0 +1,7 @@ +stages()->where('is_initial',true)->first() ?? $definition->stages()->firstOrFail(); $instance=WorkflowInstance::updateOrCreate(['subject_type'=>$subject->getMorphClass(),'subject_id'=>$subject->getKey()],['workflow_definition_id'=>$definition->getKey(),'workflow_stage_id'=>$stage->getKey(),'status'=>$stage->is_terminal?'completed':'active','started_by'=>$user?->getAuthIdentifier(),'due_at'=>$stage->due_days?now()->addDays($stage->due_days):null,'completed_at'=>$stage->is_terminal?now():null]); WorkflowHistory::create(['workflow_instance_id'=>$instance->id,'to_stage_id'=>$stage->id,'user_id'=>$user?->getAuthIdentifier(),'action'=>'started']); return $instance->fresh(['definition','stage']); }); } + public function transition(WorkflowInstance $instance, string $action, ?Authenticatable $user=null, ?string $comment=null): WorkflowInstance { return DB::transaction(function() use($instance,$action,$user,$comment){ $transition=WorkflowTransition::where('workflow_definition_id',$instance->workflow_definition_id)->where('from_stage_id',$instance->workflow_stage_id)->where('action',$action)->first(); if(!$transition) throw ValidationException::withMessages(['action'=>'This transition is not available.']); if($transition->requires_comment && blank($comment)) throw ValidationException::withMessages(['comment'=>'A comment is required.']); if($transition->required_permission && (!$user || !method_exists($user,'can') || !$user->can($transition->required_permission))) abort(403); $target=\Modules\Workflow\Models\WorkflowStage::findOrFail($transition->to_stage_id); $from=$instance->workflow_stage_id; $instance->update(['workflow_stage_id'=>$target->id,'status'=>$target->is_terminal?'completed':'active','due_at'=>$target->due_days?now()->addDays($target->due_days):null,'completed_at'=>$target->is_terminal?now():null]); WorkflowHistory::create(['workflow_instance_id'=>$instance->id,'from_stage_id'=>$from,'to_stage_id'=>$target->id,'user_id'=>$user?->getAuthIdentifier(),'action'=>$action,'comment'=>$comment]); return $instance->fresh(['definition','stage','subject']); }); } +} diff --git a/Modules/Workflow/Tests/Feature/ModuleBootTest.php b/Modules/Workflow/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..763e90d --- /dev/null +++ b/Modules/Workflow/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,4 @@ +assertTrue(app()->providerIsLoaded(\Modules\Workflow\Providers\WorkflowServiceProvider::class)); } } diff --git a/Modules/Workflow/module.json b/Modules/Workflow/module.json new file mode 100644 index 0000000..2a022f0 --- /dev/null +++ b/Modules/Workflow/module.json @@ -0,0 +1,11 @@ +{ + "name": "Workflow", + "alias": "workflow", + "description": "Reusable approval workflows, review queues, transitions and audit history.", + "keywords": ["workflow", "approval", "review"], + "priority": 35, + "providers": [ + "Modules\\Workflow\\Providers\\WorkflowServiceProvider" + ], + "files": [] +} diff --git a/modules_statuses.json b/modules_statuses.json index b21c7fe..4b145f9 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -30,5 +30,6 @@ "Newsletter": true, "Events": true, "Careers": true, - "Locations": true + "Locations": true, + "Workflow": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 2d8d3d6..5628076 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -32,6 +32,7 @@ {{ __('Testimonials') }} {{ __('Dashboard') }}{{ __('Administration') }} +{{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }} {{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}