Skip to content

Commit

Permalink
Merge pull request #41 from cjmellor/feat/rollbacks
Browse files Browse the repository at this point in the history
feat: Rollback Approvals
  • Loading branch information
cjmellor authored Oct 10, 2023
2 parents 34d8ad1 + 423f66a commit 563ac0e
Show file tree
Hide file tree
Showing 10 changed files with 211 additions and 29 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,34 @@ If you don't want to persist to the database on approval, set a `false` flag on
Approval::find(1)->approve(persist: false);
```

## Rollbacks

If you need to roll back an approval, you can use the `rollback` method.

```php
Approval::first()->rollback();
```

This will revert the data and set the state to `pending` and touch the `rolled_back_at` timestamp, so you have a record of when it was rolled back.

### Conditional Rollbacks

A roll-back can be conditional, so you can roll back an approval if a condition is met.

```php
Approval::first()->rollback(fn () => true);
```

### Events

When a Model has been rolled back, a `ModelRolledBack` event will be fired with the Approval Model that was rolled back, as well as the User that rolled it back.

```php
// ModelRolledBackEvent::class

public Model $approval,
public Authenticatable|null $user,
````
## Disable Approvals

If you don't want Model data to be approved, you can bypass it with the `withoutApproval` method.
Expand Down
19 changes: 19 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Upgrade Guide

## v1.3.1 -> v1.4.0

To support the new `rollback` functionality, a new migration file is needed

```bash
2023_10_09_204810_add_rolled_back_at_column_to_approvals_table
```

Be sure to migrate your database if you plan on using the `rollback` feature.

If you'd prefer to do it manually, you can add the following column to your `approvals` table:

```php
Schema::table('approvals', function (Blueprint $table) {
$table->timestamp(column: 'rolled_back_at')->nullable()->after('original_data');
});
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
{
Schema::table('approvals', function (Blueprint $table) {
$table->timestamp(column: 'rolled_back_at')->nullable()->after('original_data');
});
}

public function down(): void
{
Schema::table('approvals', function (Blueprint $table) {
$table->dropColumn(columns: 'rolled_back_at');
});
}
};
5 changes: 4 additions & 1 deletion src/ApprovalServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public function configurePackage(Package $package): void
$package
->name(name: 'approval')
->hasConfigFile()
->hasMigration(migrationFileName: '2022_02_12_195950_create_approvals_table');
->hasMigrations([
'2022_02_12_195950_create_approvals_table',
'2023_10_09_204810_add_rolled_back_at_column_to_approvals_table',
]);
}
}
18 changes: 18 additions & 0 deletions src/Events/ModelRolledBackEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Cjmellor\Approval\Events;

use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;

class ModelRolledBackEvent
{
use Dispatchable;

public function __construct(
public Model $approval,
public Authenticatable|null $user,
) {
}
}
30 changes: 30 additions & 0 deletions src/Models/Approval.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
namespace Cjmellor\Approval\Models;

use Cjmellor\Approval\Enums\ApprovalStatus;
use Cjmellor\Approval\Events\ModelRolledBackEvent;
use Cjmellor\Approval\Scopes\ApprovalStateScope;
use Closure;
use Exception;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\Event;

class Approval extends Model
{
Expand All @@ -16,6 +20,7 @@ class Approval extends Model
'new_data' => AsArrayObject::class,
'original_data' => AsArrayObject::class,
'state' => ApprovalStatus::class,
'rolled_back_at' => 'datetime',
];

public static function booted(): void
Expand Down Expand Up @@ -69,4 +74,29 @@ public function rejectUnless(bool $boolean): void
$this->reject();
}
}

public function rollback(Closure $condition = null): void
{
if ($condition && ! $condition($this)) {
return;
}

throw_if(
condition: $this->state !== ApprovalStatus::Approved,
exception: Exception::class,
message: 'Cannot rollback an Approval that has not been approved.'
);

$model = $this->approvalable;
$model->update($this->original_data->getArrayCopy());

$this->update([
'state' => ApprovalStatus::Pending,
'new_data' => $this->original_data,
'original_data' => $this->new_data,
'rolled_back_at' => now(),
]);

Event::dispatch(new ModelRolledBackEvent(approval: $this, user: auth()->user()));
}
}
69 changes: 69 additions & 0 deletions tests/Feature/Models/ApprovalTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

use Cjmellor\Approval\Enums\ApprovalStatus;
use Cjmellor\Approval\Events\ModelRolledBackEvent;
use Cjmellor\Approval\Tests\Models\FakeModel;
use Illuminate\Support\Facades\Event;

test(description: 'an Approved Model can be rolled back', closure: function (): void {
// Build a query
$fakeModel = new FakeModel();

$fakeModel->name = 'Bob';
$fakeModel->meta = 'green';

// Save the model, bypassing approval
$fakeModel->withoutApproval()->save();

// Update a fresh instance of the model
$fakeModel->fresh()->update(['name' => 'Chris']);

// Approve the new changes
$fakeModel->fresh()->approvals()->first()->approve();

// Test for Events
Event::fake();

// Rollback the data
$fakeModel->fresh()->approvals()->first()->rollback();

// Check the model has been rolled back
expect($fakeModel->fresh()->approvals()->first())
->state->toBe(expected: ApprovalStatus::Pending)
->new_data->toMatchArray(['name' => 'Bob'])
->original_data->toMatchArray(['name' => 'Chris'])
->rolled_back_at->not->toBeNull();

// Assert the Events were fired
Event::assertDispatched(function (ModelRolledBackEvent $event) use ($fakeModel): bool {
return $event->approval->is($fakeModel->fresh()->approvals()->first())
&& $event->user === null;
});
});

test(description: 'a rolled back Approval can be conditionally set', closure: function () {
// Build a query
$fakeModel = new FakeModel();

$fakeModel->name = 'Bob';
$fakeModel->meta = 'green';

// Save the model, bypassing approval
$fakeModel->withoutApproval()->save();

// Update a fresh instance of the model
$fakeModel->fresh()->update(['name' => 'Chris']);

// Approve the new changes
$fakeModel->fresh()->approvals()->first()->approve();

// Conditionally rollback the data
$fakeModel->fresh()->approvals()->first()->rollback(fn () => true);

// Check the model has been rolled back
expect($fakeModel->fresh()->approvals()->first())
->state->toBe(expected: ApprovalStatus::Pending)
->new_data->toMatchArray(['name' => 'Bob'])
->original_data->toMatchArray(['name' => 'Chris'])
->rolled_back_at->not->toBeNull();
});
23 changes: 4 additions & 19 deletions tests/Feature/MustBeApprovedTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,18 @@
use Cjmellor\Approval\Models\Approval;
use Cjmellor\Approval\Tests\Models\FakeModel;

beforeEach(closure: function (): void {
$this->approvalData = [
'approvalable_type' => 'App\Models\FakeModel',
'approvalable_id' => 1,
'state' => ApprovalStatus::Pending,
'new_data' => json_encode(['name' => 'Chris']),
'original_data' => json_encode(['name' => 'Bob']),
];

$this->fakeModelData = [
'name' => 'Chris',
'meta' => 'red',
];
});

it(description: 'stores the data correctly in the database')
->tap(
->defer(
fn (): Approval => Approval::create($this->approvalData)
)->assertDatabaseHas('approvals', [
'approvalable_type' => 'App\Models\FakeModel',
'approvalable_type' => FakeModel::class,
'approvalable_id' => 1,
'state' => ApprovalStatus::Pending,
]);

test(description: 'an approvals model is created when a model is created with MustBeApproved trait set')
// create a fake model
->tap(callable: fn () => FakeModel::create($this->fakeModelData))
->defer(callable: fn () => FakeModel::create($this->fakeModelData))
// check it has been put in the approvals' table before the fake_models table
->assertDatabaseHas('approvals', [
'new_data' => json_encode([
Expand Down Expand Up @@ -92,7 +77,7 @@
'new_data' => json_encode($this->fakeModelData),
]);

// sanity check that is hasn't been added to the fake_models table
// check that it hasn't been added to the fake_models table
$this->assertDatabaseMissing('fake_models', $this->fakeModelData);

// approve the model
Expand Down
7 changes: 0 additions & 7 deletions tests/Feature/Scopes/ApprovalStateScopeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@
use Cjmellor\Approval\Tests\Models\FakeModel;
use Illuminate\Support\Facades\Event;

beforeEach(closure: function (): void {
$this->fakeModelData = [
'name' => 'Chris',
'meta' => 'red',
];
});

test('Check if an Approval Model is approved', closure: function (): void {
$this->approvalData = [
'approvalable_type' => 'App\Models\FakeModel',
Expand Down
20 changes: 18 additions & 2 deletions tests/Pest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
<?php

use Cjmellor\Approval\Enums\ApprovalStatus;
use Cjmellor\Approval\Tests\Models\FakeModel;
use Cjmellor\Approval\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(TestCase::class)->in(__DIR__);
uses(RefreshDatabase::class);
uses(TestCase::class, RefreshDatabase::class)
->beforeEach(hook: function (): void {
$this->approvalData = [
'approvalable_type' => FakeModel::class,
'approvalable_id' => 1,
'state' => ApprovalStatus::Pending,
'new_data' => json_encode(['name' => 'Chris']),
'original_data' => json_encode(['name' => 'Bob']),
];

$this->fakeModelData = [
'name' => 'Chris',
'meta' => 'red',
];
})
->in(__DIR__);

0 comments on commit 563ac0e

Please sign in to comment.