diff --git a/app/Policy.php b/app/Policy.php new file mode 100644 index 00000000..9faa6b0d --- /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 00000000..fb30e173 --- /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 { + 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 00000000..d1b3be37 --- /dev/null +++ b/database/migrations/2026_06_22_083853_create_policies_table.php @@ -0,0 +1,32 @@ +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 00000000..9abfbd25 --- /dev/null +++ b/database/migrations/2026_06_22_083910_create_policy_acceptances_table.php @@ -0,0 +1,37 @@ +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 these 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/tests/PolicyAcceptanceTest.php b/tests/PolicyAcceptanceTest.php new file mode 100644 index 00000000..4396fd7f --- /dev/null +++ b/tests/PolicyAcceptanceTest.php @@ -0,0 +1,60 @@ +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); + $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); + } +} diff --git a/tests/PolicyTest.php b/tests/PolicyTest.php new file mode 100644 index 00000000..bec66b92 --- /dev/null +++ b/tests/PolicyTest.php @@ -0,0 +1,27 @@ + 'terms-of-use', + 'active_from' => CarbonImmutable::createMidnightDate(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', + ]); + } +}