From b5f898def4b9e3d65aee668439f49ec04fa23ff4 Mon Sep 17 00:00:00 2001 From: Ollie Date: Tue, 23 Jun 2026 00:04:21 +0200 Subject: [PATCH 1/9] Create schemas and models for implementing generic policies Bug: T428175 --- app/Policy.php | 55 +++++++++++++++++++ app/PolicyAcceptance.php | 55 +++++++++++++++++++ ...026_06_22_083853_create_policies_table.php | 36 ++++++++++++ ...083910_create_policy_acceptances_table.php | 38 +++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 app/Policy.php create mode 100644 app/PolicyAcceptance.php create mode 100644 database/migrations/2026_06_22_083853_create_policies_table.php create mode 100644 database/migrations/2026_06_22_083910_create_policy_acceptances_table.php diff --git a/app/Policy.php b/app/Policy.php new file mode 100644 index 00000000..59304b1a --- /dev/null +++ b/app/Policy.php @@ -0,0 +1,55 @@ +|Policy newModelQuery() + * @method static Builder|Policy newQuery() + * @method static Builder|Policy query() + * @method static Builder|Policy whereActiveFrom($value) + * @method static Builder|Policy whereContentVueFile($value) + * @method static Builder|Policy whereCreatedAt($value) + * @method static Builder|Policy whereId($value) + * @method static Builder|Policy wherePolicyType($value) + * @method static Builder|Policy whereUpdatedAt($value) + * + * @mixin Eloquent + */ +class Policy extends Model { + // TODO: also create a factory (and seeder)? + + // 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', + + // TODO: should we make Laravel use CarbonImmutable globally instead of casting in models? + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + ]; + } +} diff --git a/app/PolicyAcceptance.php b/app/PolicyAcceptance.php new file mode 100644 index 00000000..be140a92 --- /dev/null +++ b/app/PolicyAcceptance.php @@ -0,0 +1,55 @@ +|PolicyAcceptance newModelQuery() + * @method static Builder|PolicyAcceptance newQuery() + * @method static Builder|PolicyAcceptance query() + * @method static Builder|PolicyAcceptance whereAcceptedAt($value) + * @method static Builder|PolicyAcceptance whereCreatedAt($value) + * @method static Builder|PolicyAcceptance whereId($value) + * @method static Builder|PolicyAcceptance wherePolicyId($value) + * @method static Builder|PolicyAcceptance whereUpdatedAt($value) + * @method static Builder|PolicyAcceptance whereUserId($value) + * + * @mixin Eloquent + */ +class PolicyAcceptance extends Model { + // TODO: also create a factory (and seeder)? + + // define which attributes are mass assignable + protected $fillable = [ + 'user_id', + 'policy_id', + // 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', + + // TODO: should we make Laravel use CarbonImmutable globally instead of casting in models? + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + ]; + } +} diff --git a/database/migrations/2026_06_22_083853_create_policies_table.php b/database/migrations/2026_06_22_083853_create_policies_table.php new file mode 100644 index 00000000..004dc1e3 --- /dev/null +++ b/database/migrations/2026_06_22_083853_create_policies_table.php @@ -0,0 +1,36 @@ +id(); + // TODO: or should this column name just be `type`? + $table->enum('policy_type', ['terms-of-use', 'hosting-policy']); + $table->date('active_from')->nullable()->default(null); + // TODO: or `content_reference`? + $table->string('content_vue_file', 255); + + // explicitly define the default timestamp columns so that they are not nullable + $table->timestamp('created_at'); + $table->timestamp('updated_at'); + + // TODO: won't be able to create two upcoming policies of the same type with `active_from` set to `null`, + // but that seems like a reasonable restriction + $table->unique(['policy_type', 'active_from']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('policies'); + } +}; diff --git a/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php new file mode 100644 index 00000000..11c119a8 --- /dev/null +++ b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php @@ -0,0 +1,38 @@ +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(); + + // Using a separate `accepted_at` column rather than renaming the default `created_at` column because: + // * it reduces confuses 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(); + + // explicitly define the default timestamp columns so that they are not nullable + $table->timestamp('created_at'); + $table->timestamp('updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('policy_acceptances'); + } +}; From 695a643edd47719d776ed4ad1cab6841274b6b14 Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 11:03:24 +0100 Subject: [PATCH 2/9] Use Eloquent built in timestamps We are using the built in Eqloquent timestamp method to introduce nullable timestamp columns. The framework seems to have discussed these issues at length[1] and determined that this is the best setup to prevent issues with ON UPDATE CURRENT_TIMESTAMP behaviour. [1] https://github.com/laravel/framework/issues/12490 --- .../2026_06_22_083853_create_policies_table.php | 6 +++--- .../2026_06_22_083910_create_policy_acceptances_table.php | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/database/migrations/2026_06_22_083853_create_policies_table.php b/database/migrations/2026_06_22_083853_create_policies_table.php index 004dc1e3..878da1d3 100644 --- a/database/migrations/2026_06_22_083853_create_policies_table.php +++ b/database/migrations/2026_06_22_083853_create_policies_table.php @@ -17,9 +17,9 @@ public function up(): void { // TODO: or `content_reference`? $table->string('content_vue_file', 255); - // explicitly define the default timestamp columns so that they are not nullable - $table->timestamp('created_at'); - $table->timestamp('updated_at'); + // Use Eloquent built in to create nullable `created_at` and `updated_at` + // timestamp fields + $table->timestamps(); // TODO: won't be able to create two upcoming policies of the same type with `active_from` set to `null`, // but that seems like a reasonable restriction diff --git a/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php index 11c119a8..f408638f 100644 --- a/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php +++ b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php @@ -18,14 +18,14 @@ public function up(): void { $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 confuses 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(); - - // explicitly define the default timestamp columns so that they are not nullable - $table->timestamp('created_at'); - $table->timestamp('updated_at'); }); } From 3c34946587e1a2a973042c36a9568b92ce5ece2f Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 11:16:35 +0100 Subject: [PATCH 3/9] typo fix --- .../2026_06_22_083910_create_policy_acceptances_table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php index f408638f..791af1bb 100644 --- a/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php +++ b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php @@ -23,7 +23,7 @@ public function up(): void { $table->timestamps(); // Using a separate `accepted_at` column rather than renaming the default `created_at` column because: - // * it reduces confuses by remaining consistent with other tables that use the default columns + // * 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(); }); From 3077811982f4693a8e771702a61a2e3f059e61b9 Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 12:14:14 +0100 Subject: [PATCH 4/9] Add small test for Policy --- tests/PolicyTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/PolicyTest.php diff --git a/tests/PolicyTest.php b/tests/PolicyTest.php new file mode 100644 index 00000000..875081d5 --- /dev/null +++ b/tests/PolicyTest.php @@ -0,0 +1,29 @@ + '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', + ]); + } +} From 68e1d548511ebbc19e1df63f4afbf7dd2257d5e4 Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 12:33:28 +0100 Subject: [PATCH 5/9] Add policy acceptance test --- tests/PolicyAcceptanceTest.php | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/PolicyAcceptanceTest.php diff --git a/tests/PolicyAcceptanceTest.php b/tests/PolicyAcceptanceTest.php new file mode 100644 index 00000000..9a3cdc8d --- /dev/null +++ b/tests/PolicyAcceptanceTest.php @@ -0,0 +1,49 @@ +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); + } +} From 19db0ceffbfd40038f2c1c36432ff1b979b2f36f Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 15:13:24 +0100 Subject: [PATCH 6/9] Remove most TODO For nice to have's like Factory and Seeder we should do in a follow-up. Still need to come to a decision on casting the timestamps; particularly the built in. --- app/Policy.php | 2 -- app/PolicyAcceptance.php | 13 ++++++++----- .../2026_06_22_083853_create_policies_table.php | 5 +---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/Policy.php b/app/Policy.php index 59304b1a..8e539f22 100644 --- a/app/Policy.php +++ b/app/Policy.php @@ -28,8 +28,6 @@ * @mixin Eloquent */ class Policy extends Model { - // TODO: also create a factory (and seeder)? - // define which attributes are mass assignable protected $fillable = [ 'policy_type', diff --git a/app/PolicyAcceptance.php b/app/PolicyAcceptance.php index be140a92..b0535be3 100644 --- a/app/PolicyAcceptance.php +++ b/app/PolicyAcceptance.php @@ -32,14 +32,16 @@ * @mixin Eloquent */ class PolicyAcceptance extends Model { - // TODO: also create a factory (and seeder)? - - // define which attributes are mass assignable protected $fillable = [ 'user_id', 'policy_id', - // 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 $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 { @@ -48,6 +50,7 @@ protected function casts(): array { 'accepted_at' => 'immutable_datetime', // TODO: should we make Laravel use CarbonImmutable globally instead of casting in models? + // TODO: should we do any casting with these built ins? 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; diff --git a/database/migrations/2026_06_22_083853_create_policies_table.php b/database/migrations/2026_06_22_083853_create_policies_table.php index 878da1d3..197902a9 100644 --- a/database/migrations/2026_06_22_083853_create_policies_table.php +++ b/database/migrations/2026_06_22_083853_create_policies_table.php @@ -11,18 +11,15 @@ public function up(): void { Schema::create('policies', function (Blueprint $table) { $table->id(); - // TODO: or should this column name just be `type`? $table->enum('policy_type', ['terms-of-use', 'hosting-policy']); $table->date('active_from')->nullable()->default(null); - // TODO: or `content_reference`? $table->string('content_vue_file', 255); // Use Eloquent built in to create nullable `created_at` and `updated_at` // timestamp fields $table->timestamps(); - // TODO: won't be able to create two upcoming policies of the same type with `active_from` set to `null`, - // but that seems like a reasonable restriction + // This prevents two upcoming policies of the same type with `active_from` set to `null`, $table->unique(['policy_type', 'active_from']); }); } From f57f71cf6458b6e3166ba892564293ab179d96f7 Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 20:32:10 +0100 Subject: [PATCH 7/9] Rerun ide-helper Run php artisan ide-helper:models --reset "App\PolicyAcceptance" "App\Policy" --- app/Policy.php | 6 +++--- app/PolicyAcceptance.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/Policy.php b/app/Policy.php index 8e539f22..fe81e5e0 100644 --- a/app/Policy.php +++ b/app/Policy.php @@ -8,12 +8,12 @@ use Illuminate\Database\Eloquent\Model; /** - * @property-read int $id + * @property int $id * @property string $policy_type * @property CarbonImmutable|null $active_from * @property string $content_vue_file - * @property CarbonImmutable $created_at - * @property CarbonImmutable $updated_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at * * @method static Builder|Policy newModelQuery() * @method static Builder|Policy newQuery() diff --git a/app/PolicyAcceptance.php b/app/PolicyAcceptance.php index b0535be3..228f6a7c 100644 --- a/app/PolicyAcceptance.php +++ b/app/PolicyAcceptance.php @@ -12,12 +12,12 @@ * - 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-read int $id + * @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 - * @property CarbonImmutable $created_at - * @property CarbonImmutable $updated_at * * @method static Builder|PolicyAcceptance newModelQuery() * @method static Builder|PolicyAcceptance newQuery() From 4f36d78ed027c9e269bbcfdcc30e768a036019c6 Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 21:25:44 +0100 Subject: [PATCH 8/9] do not cast globally for now --- app/Policy.php | 1 - app/PolicyAcceptance.php | 2 -- 2 files changed, 3 deletions(-) diff --git a/app/Policy.php b/app/Policy.php index fe81e5e0..9faa6b0d 100644 --- a/app/Policy.php +++ b/app/Policy.php @@ -45,7 +45,6 @@ protected function casts(): array { // cast `active_from` to a CarbonImmutable instance rather than a string 'active_from' => 'immutable_date', - // TODO: should we make Laravel use CarbonImmutable globally instead of casting in models? 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; diff --git a/app/PolicyAcceptance.php b/app/PolicyAcceptance.php index 228f6a7c..09793e9c 100644 --- a/app/PolicyAcceptance.php +++ b/app/PolicyAcceptance.php @@ -49,8 +49,6 @@ protected function casts(): array { // cast `accepted_at` to a `CarbonImmutable` instance rather than a string 'accepted_at' => 'immutable_datetime', - // TODO: should we make Laravel use CarbonImmutable globally instead of casting in models? - // TODO: should we do any casting with these built ins? 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; From c0dc419c8147c14de325e8efa27d274c4825b5f3 Mon Sep 17 00:00:00 2001 From: Thomas Arrow Date: Wed, 24 Jun 2026 21:34:22 +0100 Subject: [PATCH 9/9] Add PolicySeeder Creates a new Seeder for easily creating Policy locally. This is not automatically run with `php artisan db:seed` since I intend to follow up with separating out Seeders into those we may wish to run only in test or dev and those we may wish to run in Prod Bug: T430090 --- database/seeds/PolicySeeder.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 database/seeds/PolicySeeder.php diff --git a/database/seeds/PolicySeeder.php b/database/seeds/PolicySeeder.php new file mode 100644 index 00000000..e29bd6a1 --- /dev/null +++ b/database/seeds/PolicySeeder.php @@ -0,0 +1,19 @@ + 'terms-of-use', + 'active_from' => CarbonImmutable::createFromDate(2026, 06, 01), + 'content_vue_file' => 'terms-of-use/example.vue', + ] + ); + } +}