diff --git a/app/Policy.php b/app/Policy.php new file mode 100644 index 000000000..9faa6b0d0 --- /dev/null +++ b/app/Policy.php @@ -0,0 +1,52 @@ +|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 { + // 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', + ]; + } +} diff --git a/app/PolicyAcceptance.php b/app/PolicyAcceptance.php new file mode 100644 index 000000000..09793e9cc --- /dev/null +++ b/app/PolicyAcceptance.php @@ -0,0 +1,56 @@ +|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 { + 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', + ]; + } +} 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 000000000..197902a9c --- /dev/null +++ b/database/migrations/2026_06_22_083853_create_policies_table.php @@ -0,0 +1,33 @@ +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'); + } +}; 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 000000000..791af1bb7 --- /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(); + + // 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'); + } +}; diff --git a/database/seeds/PolicySeeder.php b/database/seeds/PolicySeeder.php new file mode 100644 index 000000000..e29bd6a1e --- /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', + ] + ); + } +} diff --git a/tests/PolicyAcceptanceTest.php b/tests/PolicyAcceptanceTest.php new file mode 100644 index 000000000..9a3cdc8d3 --- /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); + } +} diff --git a/tests/PolicyTest.php b/tests/PolicyTest.php new file mode 100644 index 000000000..875081d58 --- /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', + ]); + } +}