Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Rollback Approvals #41

Merged
merged 5 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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__);