Skip to content
52 changes: 52 additions & 0 deletions app/Policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace App;

use Carbon\CarbonImmutable;
use Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

/**
* @property int $id
* @property string $policy_type
* @property CarbonImmutable|null $active_from
* @property string $content_vue_file
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
*
* @method static Builder<static>|Policy newModelQuery()
* @method static Builder<static>|Policy newQuery()
* @method static Builder<static>|Policy query()
* @method static Builder<static>|Policy whereActiveFrom($value)
* @method static Builder<static>|Policy whereContentVueFile($value)
* @method static Builder<static>|Policy whereCreatedAt($value)
* @method static Builder<static>|Policy whereId($value)
* @method static Builder<static>|Policy wherePolicyType($value)
* @method static Builder<static>|Policy whereUpdatedAt($value)
*
* @mixin Eloquent
*/
class Policy extends Model {
// define which attributes are mass assignable
protected $fillable = [
'policy_type',
'active_from',
'content_vue_file',
];

// define the default value of model attributes when a new instance is created
protected $attributes = [
'active_from' => null,
Comment thread
tarrow marked this conversation as resolved.
];

protected function casts(): array {
return [
// cast `active_from` to a CarbonImmutable instance rather than a string
'active_from' => 'immutable_date',

'created_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
];
}
}
55 changes: 55 additions & 0 deletions app/PolicyAcceptance.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App;

use Carbon\CarbonImmutable;
use Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

/**
* This model uses a separate `accepted_at` property rather than renaming the default `created_at` property because:
* - it remains consistent with other models that use the default timestamps
* - `accepted_at` will be before `created_at` when backfilling the terms-of-use acceptances
*
* @property int $id
* @property int $user_id
* @property int $policy_id
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
* @property CarbonImmutable $accepted_at
*
* @method static Builder<static>|PolicyAcceptance newModelQuery()
* @method static Builder<static>|PolicyAcceptance newQuery()
* @method static Builder<static>|PolicyAcceptance query()
* @method static Builder<static>|PolicyAcceptance whereAcceptedAt($value)
* @method static Builder<static>|PolicyAcceptance whereCreatedAt($value)
* @method static Builder<static>|PolicyAcceptance whereId($value)
* @method static Builder<static>|PolicyAcceptance wherePolicyId($value)
* @method static Builder<static>|PolicyAcceptance whereUpdatedAt($value)
* @method static Builder<static>|PolicyAcceptance whereUserId($value)
*
* @mixin Eloquent
*/
class PolicyAcceptance extends Model {
protected $fillable = [
'user_id',
'policy_id',
];

protected $guarded = [
Comment thread
tarrow marked this conversation as resolved.
// Don't allow `accepted_at` to be mass assigned.
// Most of the time this will be set to the current timestamp by the database.
'accepted_at',
];

protected function casts(): array {
return [
// cast `accepted_at` to a `CarbonImmutable` instance rather than a string
'accepted_at' => 'immutable_datetime',

'created_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
];
}
}
33 changes: 33 additions & 0 deletions database/migrations/2026_06_22_083853_create_policies_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

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

return new class() extends Migration {
/**
* Run the migrations.
*/
public function up(): void {
Schema::create('policies', function (Blueprint $table) {
$table->id();
$table->enum('policy_type', ['terms-of-use', 'hosting-policy']);
$table->date('active_from')->nullable()->default(null);
$table->string('content_vue_file', 255);

// Use Eloquent built in to create nullable `created_at` and `updated_at`
// timestamp fields
$table->timestamps();

// This prevents two upcoming policies of the same type with `active_from` set to `null`,
$table->unique(['policy_type', 'active_from']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void {
Schema::dropIfExists('policies');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

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

return new class() extends Migration {
/**
* Run the migrations.
*/
public function up(): void {
Schema::create('policy_acceptances', function (Blueprint $table) {
$table->id();

// Can't use the `foreignId()` method because the `users.id` column isn't an unsigned big integer
$table->unsignedInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->restrictOnUpdate()->restrictOnDelete();

$table->foreignId('policy_id')->constrained()->restrictOnUpdate()->restrictOnDelete();

// Use Eloquent built in to create nullable `created_at` and `updated_at`
// timestamp fields
$table->timestamps();

// Using a separate `accepted_at` column rather than renaming the default `created_at` column because:
// * it reduces confusion by remaining consistent with other tables that use the default columns
// * `accepted_at` will be before `created_at` when backfilling the terms-of-use acceptances
$table->timestamp('accepted_at')->useCurrent();
});
}

/**
* Reverse the migrations.
*/
public function down(): void {
Schema::dropIfExists('policy_acceptances');
}
};
60 changes: 60 additions & 0 deletions tests/PolicyAcceptanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Tests;

use App\Policy;
use App\PolicyAcceptance;
use App\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PolicyAcceptanceTest extends TestCase {
use RefreshDatabase;

protected int $userId;

protected int $policyId;

protected function setUp(): void {
parent::setUp();
$user = User::factory()->create();
$this->userId = $user->id;
$policy = Policy::create(
[
'policy_type' => 'terms-of-use',
'active_from' => CarbonImmutable::yesterday(),
'content_vue_file' => 'terms-of-use/example.vue',
]);
$this->policyId = $policy->id;
}

public function testCreatesAndSavesSuccessfully(): void {
$policyAcceptance = new PolicyAcceptance(
[
'user_id' => $this->userId,
'policy_id' => $this->policyId,
]
);
$policyAcceptance->save();
$policyAcceptance->refresh();

$this->assertDatabaseHas('policy_acceptances', [
'user_id' => $this->userId,
'policy_id' => $this->policyId,
]);

$this->assertNotEmpty($policyAcceptance->accepted_at);
Comment thread
tarrow marked this conversation as resolved.
$this->assertInstanceOf(CarbonImmutable::class, $policyAcceptance->accepted_at);
}

public function testAcceptedAtIgnoresMassAssignment(): void {
$policyAcceptance = PolicyAcceptance::create(
[
'user_id' => $this->userId,
'policy_id' => $this->policyId,
'accepted_at' => CarbonImmutable::createFromDate(2026, 1, 1),
]
);
$this->assertNull($policyAcceptance->accepted_at);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put the instance assertion here too is not a bad idea $this->assertInstanceOf(CarbonImmutable::class, $policyAcceptance->accepted_at);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's not possible: null isn't an instance of CarbonImmutable

}
}
27 changes: 27 additions & 0 deletions tests/PolicyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Tests;

use App\Policy;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PolicyTest extends TestCase {
use RefreshDatabase;

public function testCreatesSuccessfully(): void {
Policy::create(
[
'policy_type' => 'terms-of-use',
'active_from' => Carbon::createFromDate(2025, 4, 1),
'content_vue_file' => 'terms-of-use/example.vue',
]
);

$this->assertDatabaseHas('policies', [
'policy_type' => 'terms-of-use',
'active_from' => '2025-04-01',
'content_vue_file' => 'terms-of-use/example.vue',
]);
}
}
Loading