Skip to content
Open
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
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,
];

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',
];
}
}
56 changes: 56 additions & 0 deletions app/PolicyAcceptance.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?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 reduces confuses by remaining 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 = [
// 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');
}
};
19 changes: 19 additions & 0 deletions database/seeds/PolicySeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Database\Seeders;

use App\Policy;
use Carbon\CarbonImmutable;
use Illuminate\Database\Seeder;

class PolicySeeder extends Seeder {
public function run() {
Policy::create(
[
'policy_type' => 'terms-of-use',
'active_from' => CarbonImmutable::createFromDate(2026, 06, 01),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Creating a CarbonImmutable instance here works, but makes it trickier to use firstOrCreate() or updateOrCreate() methods (see other comment as to why you would want to use those methods). This is because (emphasis mine):

Create a Carbon instance from just a date. The time portion is set to now.

And explained using tinker:

$ dc exec -it api php artisan tinker
> use Carbon\CarbonImmutable;

> $a = CarbonImmutable::createFromDate(2026, 06, 01);
= Carbon\CarbonImmutable @1780325575 {#8373
    date: 2026-06-01 14:52:55.291326 UTC (+00:00),
  }

> $b = CarbonImmutable::createFromDate(2026, 06, 01);
= Carbon\CarbonImmutable @1780325594 {#8399
    date: 2026-06-01 14:53:14.547747 UTC (+00:00),
  }

> $a === $b
= false

> $a == $b
= false

> $a->equalTo($b)
= false

To address this you could either:

  • use a date string (e.g. 'active_from' => '2026-06-01',)
  • use the CarbonImmutable::createMidnightDate() method
> $a = CarbonImmutable::createMidnightDate(2026, 06, 01);
= Carbon\CarbonImmutable @1780272000 {#8474
    date: 2026-06-01 00:00:00.0 UTC (+00:00),
  }

> $b = CarbonImmutable::createMidnightDate(2026, 06, 01);
= Carbon\CarbonImmutable @1780272000 {#8451
    date: 2026-06-01 00:00:00.0 UTC (+00:00),
  }

> $a === $b
= false

> $a == $b
= true

> $a->equalTo($b)
= true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I did some more testing of creating CarbonImmutable objects while writing a test for another PR and thought I would paste the findings here as well.

echo CarbonImmutable::create(2026, 06, 01);              // 2026-06-01 00:00:00
echo CarbonImmutable::createFromDate(2026, 06, 01);      // 2026-06-01 16:01:49
echo CarbonImmutable::createMidnightDate(2026, 06, 01);  // 2026-06-01 00:00:00
echo CarbonImmutable::create(2026, 06, 01, 12);          // 2026-06-01 12:00:00

'content_vue_file' => 'terms-of-use/example.vue',
]
);

@outdooracorn outdooracorn Jun 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe this will create a new policy, or fail if the policy already exists. If multiple seeders are run in the future, I think a failed Seeder will prevent subsequent seeders from running, which is likely not what we want.

From my quick searching, it's often recommended to create seeders in an idempotent way (i.e. running the seeder multiple times will result in the same database state).

https://oneuptime.com/blog/post/2026-02-03-laravel-database-seeding/view recommends using firstOrCreate() or updateOrCreate() methods on the model to make seeders idempotent.

Alternatively/additionally, you could use the Policy::truncate(); method to clear the table and reset the index (I haven't thought much about how best to handle existing constraints, though).

}
Comment on lines +10 to +18

@outdooracorn outdooracorn Jun 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For testing purposes, would it be useful to have more than one current policy? Maybe also a superseded policy? I'm also happy if we add these at a later stage, though.

}
49 changes: 49 additions & 0 deletions tests/PolicyAcceptanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?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 $user_id;

protected int $policy_id;

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

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

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

$this->assertNotEmpty($policyAcceptance->accepted_at);
}
}
29 changes: 29 additions & 0 deletions tests/PolicyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Tests;

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

class PolicyTest extends TestCase {
use RefreshDatabase;

public function testCreatesAndSavesSuccessfully(): void {
$yesterday = Carbon::yesterday();
$policy = Policy::create(
[
'policy_type' => 'terms-of-use',
'active_from' => $yesterday,
'content_vue_file' => 'terms-of-use/example.vue',
]
);
$policy->save();

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