From 502c9edec53fb76a1b0327283f5eb11aea5a0e5d Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Sun, 4 Oct 2026 19:16:40 +0100 Subject: [PATCH 1/8] feat: created the saved_search db, model, factory, resource and frontend --- .../Controllers/SavedSearchController.php | 50 ++++++ app/Http/Requests/SavedSearchRequest.php | 46 +++++ app/Http/Resources/SavedSearchResource.php | 29 ++++ app/Models/SavedSearch.php | 55 ++++++ app/Models/User.php | 9 + app/Policies/SavedSearchPolicy.php | 19 ++ database/factories/SavedSearchFactory.php | 33 ++++ ..._04_141649_create_saved_searches_table.php | 28 +++ resources/js/pages/SavedSearches/Index.vue | 46 +++++ routes/web.php | 5 + tests/Feature/SavedSearchTest.php | 162 ++++++++++++++++++ 11 files changed, 482 insertions(+) create mode 100644 app/Http/Controllers/SavedSearchController.php create mode 100644 app/Http/Requests/SavedSearchRequest.php create mode 100644 app/Http/Resources/SavedSearchResource.php create mode 100644 app/Models/SavedSearch.php create mode 100644 app/Policies/SavedSearchPolicy.php create mode 100644 database/factories/SavedSearchFactory.php create mode 100644 database/migrations/2026_10_04_141649_create_saved_searches_table.php create mode 100644 resources/js/pages/SavedSearches/Index.vue create mode 100644 tests/Feature/SavedSearchTest.php diff --git a/app/Http/Controllers/SavedSearchController.php b/app/Http/Controllers/SavedSearchController.php new file mode 100644 index 0000000..e333ce2 --- /dev/null +++ b/app/Http/Controllers/SavedSearchController.php @@ -0,0 +1,50 @@ +user() + ->savedSearches() + ->latest() + ->get(); + + return Inertia::render('SavedSearches/Index', [ + 'savedSearches' => SavedSearchResource::collection($searches), + 'regions' => Branch::query()->distinct()->orderBy('region')->pluck('region'), + 'propertyTypes' => PropertyType::options(), + ]); + } + + public function store(SavedSearchRequest $request): RedirectResponse + { + $request->user()->savedSearches()->create( + $request->validated(), + ); + + return redirect()->route('saved-searches.index'); + } + + public function destroy(Request $request, SavedSearch $savedSearch): RedirectResponse + { + if ($request->user()->cannot('delete', $savedSearch)) { + abort(403); + } + + $savedSearch->delete(); + + return redirect()->route('saved-searches.index'); + } +} diff --git a/app/Http/Requests/SavedSearchRequest.php b/app/Http/Requests/SavedSearchRequest.php new file mode 100644 index 0000000..ed6b62a --- /dev/null +++ b/app/Http/Requests/SavedSearchRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + return [ + 'max_price' => ['nullable', 'integer', 'min:0'], + 'min_bedrooms' => ['nullable', 'integer', 'min:0', 'max:20'], + 'property_type' => ['nullable', new Enum(PropertyType::class)], + 'region' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * A saved search with no criteria would match everything, which is not + * useful and would generate noise. Require at least one. + * + * @param Validator $validator + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $criteria = ['max_price', 'min_bedrooms', 'property_type', 'region']; + + if (! collect($criteria)->contains(fn (string $field) => $this->filled($field))) { + $validator->errors()->add('criteria', 'At least one search criterion is required.'); + } + }); + } +} diff --git a/app/Http/Resources/SavedSearchResource.php b/app/Http/Resources/SavedSearchResource.php new file mode 100644 index 0000000..8e66df5 --- /dev/null +++ b/app/Http/Resources/SavedSearchResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'max_price' => $this->max_price, + 'min_bedrooms' => $this->min_bedrooms, + 'property_type' => $this->property_type?->value, + 'property_type_label' => $this->property_type?->label(), + 'region' => $this->region, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Models/SavedSearch.php b/app/Models/SavedSearch.php new file mode 100644 index 0000000..fdf0620 --- /dev/null +++ b/app/Models/SavedSearch.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'max_price', + 'min_bedrooms', + 'property_type', + 'region', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'max_price' => 'integer', + 'min_bedrooms' => 'integer', + 'property_type' => PropertyType::class, + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..650f821 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -29,4 +30,12 @@ protected function casts(): array 'password' => 'hashed', ]; } + + /** + * @return HasMany + */ + public function savedSearches(): HasMany + { + return $this->hasMany(SavedSearch::class); + } } diff --git a/app/Policies/SavedSearchPolicy.php b/app/Policies/SavedSearchPolicy.php new file mode 100644 index 0000000..08c9a7d --- /dev/null +++ b/app/Policies/SavedSearchPolicy.php @@ -0,0 +1,19 @@ +id === $savedSearch->user_id; + } + + public function delete(User $user, SavedSearch $savedSearch): bool + { + return $user->id === $savedSearch->user_id; + } +} diff --git a/database/factories/SavedSearchFactory.php b/database/factories/SavedSearchFactory.php new file mode 100644 index 0000000..1395b28 --- /dev/null +++ b/database/factories/SavedSearchFactory.php @@ -0,0 +1,33 @@ + + */ +class SavedSearchFactory extends Factory +{ + protected $model = SavedSearch::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'max_price' => fake()->numberBetween(150, 800) * 1000, + 'min_bedrooms' => fake()->numberBetween(1, 4), + 'property_type' => fake()->randomElement(PropertyType::cases()), + 'region' => fake()->randomElement([ + 'Manchester', 'Leeds', 'Liverpool', 'Sheffield', + 'Newcastle', 'Nottingham', 'Bristol', 'Birmingham', + ]), + ]; + } +} diff --git a/database/migrations/2026_10_04_141649_create_saved_searches_table.php b/database/migrations/2026_10_04_141649_create_saved_searches_table.php new file mode 100644 index 0000000..c259f3b --- /dev/null +++ b/database/migrations/2026_10_04_141649_create_saved_searches_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('max_price')->nullable(); + $table->unsignedTinyInteger('min_bedrooms')->nullable(); + $table->string('property_type')->nullable(); + $table->string('region')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_searches'); + } +}; diff --git a/resources/js/pages/SavedSearches/Index.vue b/resources/js/pages/SavedSearches/Index.vue new file mode 100644 index 0000000..0a71220 --- /dev/null +++ b/resources/js/pages/SavedSearches/Index.vue @@ -0,0 +1,46 @@ + + + diff --git a/routes/web.php b/routes/web.php index ce04c19..004f65d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,12 @@ name('listings.index'); Route::get('/listings/{listing}', [ListingController::class, 'show'])->name('listings.show'); + +Route::get('/saved-searches', [SavedSearchController::class, 'index'])->name('saved-searches.index'); +Route::post('/saved-searches', [SavedSearchController::class, 'store'])->name('saved-searches.store'); +Route::delete('/saved-searches/{savedSearch}', [SavedSearchController::class, 'destroy'])->name('saved-searches.destroy'); diff --git a/tests/Feature/SavedSearchTest.php b/tests/Feature/SavedSearchTest.php new file mode 100644 index 0000000..f120174 --- /dev/null +++ b/tests/Feature/SavedSearchTest.php @@ -0,0 +1,162 @@ +orderBy('id')->first(); + } + + public function test_index_lists_the_authenticated_users_saved_searches(): void + { + $this->seed(); + + SavedSearch::factory(3)->for($this->demoUser())->create(); + SavedSearch::factory(2)->create(); + + $this->get('/saved-searches') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('SavedSearches/Index') + ->has('savedSearches', 3) + ); + } + + public function test_index_provides_region_and_property_type_options(): void + { + $this->seed(); + + $this->get('/saved-searches') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('regions') + ->has('propertyTypes', count(PropertyType::cases()), fn (AssertableInertia $type) => $type + ->hasAll('value', 'label') + ) + ); + } + + public function test_store_creates_a_saved_search_for_the_authenticated_user(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'max_price' => 350000, + 'min_bedrooms' => 2, + 'property_type' => 'flat', + 'region' => 'Manchester', + ])->assertRedirect(route('saved-searches.index')); + + $this->assertDatabaseHas('saved_searches', [ + 'user_id' => $this->demoUser()->id, + 'max_price' => 350000, + 'min_bedrooms' => 2, + 'property_type' => 'flat', + 'region' => 'Manchester', + ]); + } + + public function test_store_accepts_partial_criteria(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'region' => 'Leeds', + ])->assertRedirect(route('saved-searches.index')); + + $this->assertDatabaseHas('saved_searches', [ + 'user_id' => $this->demoUser()->id, + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => 'Leeds', + ]); + } + + public function test_store_rejects_when_no_criteria_are_provided(): void + { + $this->seed(); + + $this->post('/saved-searches', []) + ->assertSessionHasErrors('criteria'); + + $this->assertDatabaseCount('saved_searches', 0); + } + + public function test_store_rejects_all_blank_criteria(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'max_price' => '', + 'min_bedrooms' => '', + 'property_type' => '', + 'region' => '', + ])->assertSessionHasErrors('criteria'); + + $this->assertDatabaseCount('saved_searches', 0); + } + + public function test_store_validates_property_type(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'property_type' => 'castle', + ])->assertSessionHasErrors('property_type'); + } + + public function test_store_validates_max_price_is_non_negative(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'max_price' => -1, + ])->assertSessionHasErrors('max_price'); + } + + public function test_store_validates_min_bedrooms_range(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'min_bedrooms' => 21, + ])->assertSessionHasErrors('min_bedrooms'); + } + + public function test_destroy_deletes_the_users_saved_search(): void + { + $this->seed(); + + $search = SavedSearch::factory()->for($this->demoUser())->create(); + + $this->delete("/saved-searches/{$search->id}") + ->assertRedirect(route('saved-searches.index')); + + $this->assertDatabaseMissing('saved_searches', ['id' => $search->id]); + } + + public function test_destroy_rejects_another_users_saved_search(): void + { + $this->seed(); + + $otherUser = User::factory()->create(); + $search = SavedSearch::factory()->for($otherUser)->create(); + + $this->delete("/saved-searches/{$search->id}") + ->assertForbidden(); + + $this->assertDatabaseHas('saved_searches', ['id' => $search->id]); + } +} From 22f5de7c871bceaa576d5efd22be3e8d00943cb5 Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Sun, 4 Oct 2026 19:41:17 +0100 Subject: [PATCH 2/8] feat: add matches function to savedSearch model, update tests --- app/Models/SavedSearch.php | 25 +++ tests/Feature/SavedSearchMatchTest.php | 234 +++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 tests/Feature/SavedSearchMatchTest.php diff --git a/app/Models/SavedSearch.php b/app/Models/SavedSearch.php index fdf0620..5265fe2 100644 --- a/app/Models/SavedSearch.php +++ b/app/Models/SavedSearch.php @@ -52,4 +52,29 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + /** + * Does the given listing satisfy every criterion on this saved search? + * Null criteria are wildcards — they do not restrict the match. + */ + public function matches(Listing $listing): bool + { + if ($this->max_price !== null && $listing->price > $this->max_price) { + return false; + } + + if ($this->min_bedrooms !== null && $listing->bedrooms < $this->min_bedrooms) { + return false; + } + + if ($this->property_type !== null && $listing->property_type !== $this->property_type) { + return false; + } + + if ($this->region !== null && $listing->branch->region !== $this->region) { + return false; + } + + return true; + } } diff --git a/tests/Feature/SavedSearchMatchTest.php b/tests/Feature/SavedSearchMatchTest.php new file mode 100644 index 0000000..ca2023e --- /dev/null +++ b/tests/Feature/SavedSearchMatchTest.php @@ -0,0 +1,234 @@ +create(['region' => $attributes['region'] ?? 'Manchester']); + unset($attributes['region']); + + return Listing::factory()->live()->for($branch)->create($attributes); + } + + public function test_matches_when_all_criteria_are_satisfied(): void + { + $listing = $this->makeListing([ + 'price' => 250_000, + 'bedrooms' => 3, + 'property_type' => PropertyType::Flat, + 'region' => 'Manchester', + ]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 300_000, + 'min_bedrooms' => 2, + 'property_type' => PropertyType::Flat, + 'region' => 'Manchester', + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_does_not_match_when_price_exceeds_max(): void + { + $listing = $this->makeListing(['price' => 400_000]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 300_000, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_matches_when_price_equals_max(): void + { + $listing = $this->makeListing(['price' => 300_000]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 300_000, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_does_not_match_when_bedrooms_below_minimum(): void + { + $listing = $this->makeListing(['bedrooms' => 1]); + + $search = SavedSearch::factory()->create([ + 'min_bedrooms' => 2, + 'max_price' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_matches_when_bedrooms_equal_minimum(): void + { + $listing = $this->makeListing(['bedrooms' => 2]); + + $search = SavedSearch::factory()->create([ + 'min_bedrooms' => 2, + 'max_price' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_does_not_match_when_property_type_differs(): void + { + $listing = $this->makeListing(['property_type' => PropertyType::Detached]); + + $search = SavedSearch::factory()->create([ + 'property_type' => PropertyType::Flat, + 'max_price' => null, + 'min_bedrooms' => null, + 'region' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_does_not_match_when_region_differs(): void + { + $listing = $this->makeListing(['region' => 'Leeds']); + + $search = SavedSearch::factory()->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_null_max_price_matches_any_price(): void + { + $listing = $this->makeListing(['price' => 9_999_999]); + + $search = SavedSearch::factory()->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_null_min_bedrooms_matches_any_bedroom_count(): void + { + $listing = $this->makeListing(['bedrooms' => 1]); + + $search = SavedSearch::factory()->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_null_property_type_matches_any_type(): void + { + $listing = $this->makeListing(['property_type' => PropertyType::Bungalow]); + + $search = SavedSearch::factory()->create([ + 'property_type' => null, + 'max_price' => null, + 'min_bedrooms' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_null_region_matches_any_region(): void + { + $listing = $this->makeListing(['region' => 'Newcastle']); + + $search = SavedSearch::factory()->create([ + 'region' => null, + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_all_null_criteria_matches_any_listing(): void + { + $listing = $this->makeListing(); + + $search = SavedSearch::factory()->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_partial_criteria_only_restricts_specified_fields(): void + { + $listing = $this->makeListing([ + 'price' => 500_000, + 'bedrooms' => 1, + 'property_type' => PropertyType::Terraced, + 'region' => 'Bristol', + ]); + + $search = SavedSearch::factory()->create([ + 'region' => 'Bristol', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_fails_on_first_unmet_criterion_among_mixed_criteria(): void + { + $listing = $this->makeListing([ + 'price' => 200_000, + 'bedrooms' => 4, + 'property_type' => PropertyType::Detached, + 'region' => 'Leeds', + ]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 250_000, + 'min_bedrooms' => 3, + 'property_type' => PropertyType::Detached, + 'region' => 'Manchester', + ]); + + $this->assertFalse($search->matches($listing)); + } +} From 5ab31d002af295eb74155073587c3ea856efc405 Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Sun, 4 Oct 2026 20:11:30 +0100 Subject: [PATCH 3/8] feat: create alert model and table, listing became live event, update tests --- app/Events/ListingBecameLive.php | 14 ++ app/Listeners/GenerateSavedSearchAlerts.php | 31 +++ app/Models/Alert.php | 56 +++++ app/Models/Listing.php | 3 + app/Models/User.php | 8 + app/Observers/ListingObserver.php | 28 +++ app/Providers/AppServiceProvider.php | 5 + database/factories/AlertFactory.php | 29 +++ .../2026_10_04_184703_create_alerts_table.php | 26 +++ tests/Feature/AlertGenerationTest.php | 194 ++++++++++++++++++ 10 files changed, 394 insertions(+) create mode 100644 app/Events/ListingBecameLive.php create mode 100644 app/Listeners/GenerateSavedSearchAlerts.php create mode 100644 app/Models/Alert.php create mode 100644 app/Observers/ListingObserver.php create mode 100644 database/factories/AlertFactory.php create mode 100644 database/migrations/2026_10_04_184703_create_alerts_table.php create mode 100644 tests/Feature/AlertGenerationTest.php diff --git a/app/Events/ListingBecameLive.php b/app/Events/ListingBecameLive.php new file mode 100644 index 0000000..c5d1f05 --- /dev/null +++ b/app/Events/ListingBecameLive.php @@ -0,0 +1,14 @@ +listing->loadMissing('branch'); + + SavedSearch::query() + ->cursor() + ->filter(fn (SavedSearch $search) => $search->matches($listing)) + ->each(function (SavedSearch $search) use ($listing) { + Alert::query()->firstOrCreate( + [ + 'user_id' => $search->user_id, + 'listing_id' => $listing->id, + ], + [ + 'saved_search_id' => $search->id, + ], + ); + }); + } +} diff --git a/app/Models/Alert.php b/app/Models/Alert.php new file mode 100644 index 0000000..cc08a43 --- /dev/null +++ b/app/Models/Alert.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'saved_search_id', + 'listing_id', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function savedSearch(): BelongsTo + { + return $this->belongsTo(SavedSearch::class); + } + + /** + * @return BelongsTo + */ + public function listing(): BelongsTo + { + return $this->belongsTo(Listing::class); + } +} diff --git a/app/Models/Listing.php b/app/Models/Listing.php index 45efad3..d8639f5 100644 --- a/app/Models/Listing.php +++ b/app/Models/Listing.php @@ -4,7 +4,9 @@ use App\Enums\ListingStatus; use App\Enums\PropertyType; +use App\Observers\ListingObserver; use Database\Factories\ListingFactory; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -31,6 +33,7 @@ * @property Carbon $updated_at * @property-read Branch $branch */ +#[ObservedBy(ListingObserver::class)] class Listing extends Model { /** @use HasFactory */ diff --git a/app/Models/User.php b/app/Models/User.php index 650f821..920ea91 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -38,4 +38,12 @@ public function savedSearches(): HasMany { return $this->hasMany(SavedSearch::class); } + + /** + * @return HasMany + */ + public function alerts(): HasMany + { + return $this->hasMany(Alert::class); + } } diff --git a/app/Observers/ListingObserver.php b/app/Observers/ListingObserver.php new file mode 100644 index 0000000..80280d6 --- /dev/null +++ b/app/Observers/ListingObserver.php @@ -0,0 +1,28 @@ +status === ListingStatus::Live) { + ListingBecameLive::dispatch($listing); + } + } + + public function updated(Listing $listing): void + { + if ( + $listing->wasChanged('status') + && $listing->status === ListingStatus::Live + && $listing->getOriginal('status') !== ListingStatus::Live + ) { + ListingBecameLive::dispatch($listing); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0f6d5ab..e64a757 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use App\Events\ListingBecameLive; +use App\Listeners\GenerateSavedSearchAlerts; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -24,5 +27,7 @@ public function boot(): void // envelope around every resource just gets in the way. Paginated // collections keep their data/links/meta structure regardless. JsonResource::withoutWrapping(); + + Event::listen(ListingBecameLive::class, GenerateSavedSearchAlerts::class); } } diff --git a/database/factories/AlertFactory.php b/database/factories/AlertFactory.php new file mode 100644 index 0000000..981d164 --- /dev/null +++ b/database/factories/AlertFactory.php @@ -0,0 +1,29 @@ + + */ +class AlertFactory extends Factory +{ + protected $model = Alert::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'saved_search_id' => SavedSearch::factory(), + 'listing_id' => Listing::factory()->live(), + ]; + } +} diff --git a/database/migrations/2026_10_04_184703_create_alerts_table.php b/database/migrations/2026_10_04_184703_create_alerts_table.php new file mode 100644 index 0000000..5e56165 --- /dev/null +++ b/database/migrations/2026_10_04_184703_create_alerts_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saved_search_id')->constrained()->cascadeOnDelete(); + $table->foreignId('listing_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['user_id', 'listing_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alerts'); + } +}; diff --git a/tests/Feature/AlertGenerationTest.php b/tests/Feature/AlertGenerationTest.php new file mode 100644 index 0000000..83ef259 --- /dev/null +++ b/tests/Feature/AlertGenerationTest.php @@ -0,0 +1,194 @@ +create(['region' => 'Manchester']); + $user = User::factory()->create(); + + $search = SavedSearch::factory()->for($user)->create([ + 'max_price' => 300_000, + 'min_bedrooms' => 2, + 'property_type' => PropertyType::Flat, + 'region' => 'Manchester', + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'price' => 250_000, + 'bedrooms' => 3, + 'property_type' => PropertyType::Flat, + 'status' => ListingStatus::Draft, + ]); + + $this->assertDatabaseCount('alerts', 0); + + $listing->update([ + 'status' => ListingStatus::Live, + 'listed_at' => now(), + ]); + + $this->assertDatabaseCount('alerts', 1); + $this->assertDatabaseHas('alerts', [ + 'user_id' => $user->id, + 'saved_search_id' => $search->id, + 'listing_id' => $listing->id, + ]); + } + + public function test_no_alert_for_a_non_matching_saved_search(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => 200_000, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'price' => 400_000, + 'status' => ListingStatus::Draft, + ]); + + $listing->update([ + 'status' => ListingStatus::Live, + 'listed_at' => now(), + ]); + + $this->assertDatabaseCount('alerts', 0); + } + + public function test_no_alert_when_a_listing_is_created_as_draft(): void + { + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + Listing::factory()->draft()->create(); + + $this->assertDatabaseCount('alerts', 0); + } + + public function test_no_alert_when_an_already_live_listing_is_updated(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'status' => ListingStatus::Draft, + ]); + + $listing->update([ + 'status' => ListingStatus::Live, + 'listed_at' => now(), + ]); + + $this->assertDatabaseCount('alerts', 1); + + $listing->update(['price' => $listing->price + 10_000]); + + $this->assertDatabaseCount('alerts', 1); + } + + public function test_alert_is_generated_when_a_listing_is_created_directly_as_live(): void + { + $branch = Branch::factory()->create(['region' => 'Leeds']); + $user = User::factory()->create(); + + $search = SavedSearch::factory()->for($user)->create([ + 'region' => 'Leeds', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $listing = Listing::factory()->live()->for($branch)->create(); + + $this->assertDatabaseCount('alerts', 1); + $this->assertDatabaseHas('alerts', [ + 'user_id' => $user->id, + 'saved_search_id' => $search->id, + 'listing_id' => $listing->id, + ]); + } + + public function test_duplicate_alert_is_not_created_when_multiple_searches_match(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => 500_000, + 'region' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + Listing::factory()->live()->for($branch)->create(['price' => 250_000]); + + $this->assertDatabaseCount('alerts', 1); + } + + public function test_alerts_are_scoped_to_the_correct_users(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + + $alice = User::factory()->create(); + $bob = User::factory()->create(); + + SavedSearch::factory()->for($alice)->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + SavedSearch::factory()->for($bob)->create([ + 'region' => 'Leeds', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + Listing::factory()->live()->for($branch)->create(); + + $this->assertDatabaseCount('alerts', 1); + $this->assertDatabaseHas('alerts', ['user_id' => $alice->id]); + $this->assertDatabaseMissing('alerts', ['user_id' => $bob->id]); + } +} From 61230e06e63d1992af37811b8ac48f40e9b35e51 Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Sun, 4 Oct 2026 20:33:47 +0100 Subject: [PATCH 4/8] feat: display alerts on frontend for testing purposes --- app/Http/Controllers/AlertController.php | 24 +++ app/Http/Resources/AlertResource.php | 27 ++++ app/Models/Alert.php | 11 ++ .../2026_10_04_184703_create_alerts_table.php | 1 + resources/js/pages/Alerts/Index.vue | 73 +++++++++ routes/web.php | 3 + tests/Feature/AlertPageTest.php | 152 ++++++++++++++++++ 7 files changed, 291 insertions(+) create mode 100644 app/Http/Controllers/AlertController.php create mode 100644 app/Http/Resources/AlertResource.php create mode 100644 resources/js/pages/Alerts/Index.vue create mode 100644 tests/Feature/AlertPageTest.php diff --git a/app/Http/Controllers/AlertController.php b/app/Http/Controllers/AlertController.php new file mode 100644 index 0000000..80c8696 --- /dev/null +++ b/app/Http/Controllers/AlertController.php @@ -0,0 +1,24 @@ +user() + ->alerts() + ->with('listing.branch', 'savedSearch') + ->latest() + ->paginate(15); + + return Inertia::render('Alerts/Index', [ + 'alerts' => AlertResource::collection($alerts), + ]); + } +} diff --git a/app/Http/Resources/AlertResource.php b/app/Http/Resources/AlertResource.php new file mode 100644 index 0000000..6bb0f2b --- /dev/null +++ b/app/Http/Resources/AlertResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'listing' => new ListingResource($this->listing), + 'saved_search' => new SavedSearchResource($this->savedSearch), + 'read_at' => $this->read_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Models/Alert.php b/app/Models/Alert.php index cc08a43..8376336 100644 --- a/app/Models/Alert.php +++ b/app/Models/Alert.php @@ -13,6 +13,7 @@ * @property int $user_id * @property int $saved_search_id * @property int $listing_id + * @property Carbon|null $read_at * @property Carbon $created_at * @property Carbon $updated_at * @property-read User $user @@ -30,6 +31,16 @@ class Alert extends Model 'listing_id', ]; + /** + * @return array + */ + protected function casts(): array + { + return [ + 'read_at' => 'datetime', + ]; + } + /** * @return BelongsTo */ diff --git a/database/migrations/2026_10_04_184703_create_alerts_table.php b/database/migrations/2026_10_04_184703_create_alerts_table.php index 5e56165..12d5651 100644 --- a/database/migrations/2026_10_04_184703_create_alerts_table.php +++ b/database/migrations/2026_10_04_184703_create_alerts_table.php @@ -13,6 +13,7 @@ public function up(): void $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('saved_search_id')->constrained()->cascadeOnDelete(); $table->foreignId('listing_id')->constrained()->cascadeOnDelete(); + $table->timestamp('read_at')->nullable(); $table->timestamps(); $table->unique(['user_id', 'listing_id']); diff --git a/resources/js/pages/Alerts/Index.vue b/resources/js/pages/Alerts/Index.vue new file mode 100644 index 0000000..f42ae82 --- /dev/null +++ b/resources/js/pages/Alerts/Index.vue @@ -0,0 +1,73 @@ + + + diff --git a/routes/web.php b/routes/web.php index 004f65d..5d51f02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('saved-searches.index'); Route::post('/saved-searches', [SavedSearchController::class, 'store'])->name('saved-searches.store'); Route::delete('/saved-searches/{savedSearch}', [SavedSearchController::class, 'destroy'])->name('saved-searches.destroy'); + +Route::get('/alerts', [AlertController::class, 'index'])->name('alerts.index'); diff --git a/tests/Feature/AlertPageTest.php b/tests/Feature/AlertPageTest.php new file mode 100644 index 0000000..9ff11bf --- /dev/null +++ b/tests/Feature/AlertPageTest.php @@ -0,0 +1,152 @@ +orderBy('id')->first(); + } + + public function test_index_renders_the_authenticated_users_alerts(): void + { + $this->seed(); + $user = $this->demoUser(); + + $search = SavedSearch::factory()->for($user)->create(); + + Alert::factory(3)->for($user)->for($search)->create(); + + // Another user's alert — must not appear. + Alert::factory()->create(); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Alerts/Index') + ->has('alerts.data', 3) + ); + } + + public function test_index_does_not_show_another_users_alerts(): void + { + $this->seed(); + + $otherUser = User::factory()->create(); + Alert::factory(2)->for($otherUser)->create(); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('alerts.data', 0) + ); + } + + public function test_index_includes_listing_data_for_the_ui(): void + { + $this->seed(); + $user = $this->demoUser(); + + $branch = Branch::factory()->create(['region' => 'Manchester']); + $search = SavedSearch::factory()->for($user)->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'price' => 250_000, + 'bedrooms' => 3, + 'property_type' => PropertyType::Flat, + ]); + + Alert::factory()->for($user)->create([ + 'saved_search_id' => $search->id, + 'listing_id' => $listing->id, + ]); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('alerts.data.0', fn (AssertableInertia $alert) => $alert + ->has('id') + ->has('created_at') + ->has('read_at') + ->has('listing', fn (AssertableInertia $l) => $l + ->where('id', $listing->id) + ->where('price', 250_000) + ->where('bedrooms', 3) + ->where('property_type', 'flat') + ->has('address_line_1') + ->has('branch.region') + ->etc() + ) + ->has('saved_search', fn (AssertableInertia $s) => $s + ->where('region', 'Manchester') + ->etc() + ) + ) + ); + } + + public function test_index_is_paginated(): void + { + $this->seed(); + $user = $this->demoUser(); + + $search = SavedSearch::factory()->for($user)->create(); + + Alert::factory(20)->for($user)->for($search)->create(); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('alerts.data', 15) + ->has('alerts.links') + ); + } + + public function test_index_orders_alerts_newest_first(): void + { + $this->seed(); + $user = $this->demoUser(); + + $search = SavedSearch::factory()->for($user)->create(); + + $oldListing = Listing::factory()->create(); + $newListing = Listing::factory()->create(); + + Alert::factory()->for($user)->create([ + 'saved_search_id' => $search->id, + 'listing_id' => $oldListing->id, + 'created_at' => now()->subDay(), + ]); + + Alert::factory()->for($user)->create([ + 'saved_search_id' => $search->id, + 'listing_id' => $newListing->id, + 'created_at' => now(), + ]); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('alerts.data.0.listing.id', $newListing->id) + ->where('alerts.data.1.listing.id', $oldListing->id) + ); + } +} From 911a45e640c45aab13fafdda7ba816d78e8f081f Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Sun, 4 Oct 2026 20:46:06 +0100 Subject: [PATCH 5/8] fix: fix a few issues --- app/Http/Requests/SavedSearchRequest.php | 2 +- app/Observers/ListingObserver.php | 2 +- database/factories/AlertFactory.php | 9 ++++++++- .../migrations/2026_10_04_184703_create_alerts_table.php | 1 + 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/Http/Requests/SavedSearchRequest.php b/app/Http/Requests/SavedSearchRequest.php index ed6b62a..06aa6a5 100644 --- a/app/Http/Requests/SavedSearchRequest.php +++ b/app/Http/Requests/SavedSearchRequest.php @@ -20,7 +20,7 @@ public function authorize(): bool public function rules(): array { return [ - 'max_price' => ['nullable', 'integer', 'min:0'], + 'max_price' => ['nullable', 'integer', 'min:1'], 'min_bedrooms' => ['nullable', 'integer', 'min:0', 'max:20'], 'property_type' => ['nullable', new Enum(PropertyType::class)], 'region' => ['nullable', 'string', 'max:100'], diff --git a/app/Observers/ListingObserver.php b/app/Observers/ListingObserver.php index 80280d6..f927038 100644 --- a/app/Observers/ListingObserver.php +++ b/app/Observers/ListingObserver.php @@ -20,7 +20,7 @@ public function updated(Listing $listing): void if ( $listing->wasChanged('status') && $listing->status === ListingStatus::Live - && $listing->getOriginal('status') !== ListingStatus::Live + && $listing->getOriginal('status') !== ListingStatus::Live->value ) { ListingBecameLive::dispatch($listing); } diff --git a/database/factories/AlertFactory.php b/database/factories/AlertFactory.php index 981d164..0c8275d 100644 --- a/database/factories/AlertFactory.php +++ b/database/factories/AlertFactory.php @@ -23,7 +23,14 @@ public function definition(): array return [ 'user_id' => User::factory(), 'saved_search_id' => SavedSearch::factory(), - 'listing_id' => Listing::factory()->live(), + 'listing_id' => Listing::factory(), ]; } + + public function read(): static + { + return $this->state(fn () => [ + 'read_at' => now(), + ]); + } } diff --git a/database/migrations/2026_10_04_184703_create_alerts_table.php b/database/migrations/2026_10_04_184703_create_alerts_table.php index 12d5651..c2480da 100644 --- a/database/migrations/2026_10_04_184703_create_alerts_table.php +++ b/database/migrations/2026_10_04_184703_create_alerts_table.php @@ -16,6 +16,7 @@ public function up(): void $table->timestamp('read_at')->nullable(); $table->timestamps(); + $table->index('saved_search_id'); $table->unique(['user_id', 'listing_id']); }); } From 4fb1307191425afbde91f79df576325d3224888c Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Sun, 4 Oct 2026 20:49:58 +0100 Subject: [PATCH 6/8] feat: NOTES.md --- NOTES.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..93d10c9 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,57 @@ +# Saved-Search Alerts — Implementation Notes + +## Architecture + +A **SavedSearch** belongs to a User and stores search criteria as explicit database columns (`max_price`, `min_bedrooms`, `property_type`, `region`). Explicit columns were chosen over a JSON blob because the criteria are small, well-defined, and benefit from straightforward validation and queryability. + +Matching logic lives on the model (`SavedSearch::matches(Listing): bool`), separate from any controller or HTTP concern. It mirrors the filter semantics already used by `ListingController`: `price <=`, `bedrooms >=`, exact match on type and region. Null criteria act as wildcards. + +When a listing transitions to live, a `ListingObserver` dispatches a `ListingBecameLive` event. A `GenerateSavedSearchAlerts` listener handles the event: it iterates saved searches, applies `matches()`, and creates an **Alert** record for each user whose search is satisfied. Alerts are unique per user per listing (enforced by a database constraint), so the process is idempotent. + +``` +Listing saved (created as live, or status → live) + → ListingObserver dispatches ListingBecameLive + → GenerateSavedSearchAlerts listener + → iterates saved searches + → SavedSearch::matches(listing) + → Alert::firstOrCreate (deduplicated by user + listing) +``` + +The observer checks `wasChanged('status')` and compares against the original value, so editing an already-live listing does not re-fire. + +## Product decisions + +- **No backfill.** Alerts are only generated for listings that become live *after* a search is saved. Existing live listings are discoverable through normal search — flooding the alerts list on save would be noisy. +- **One alert per user per listing.** If a listing matches two of a user's saved searches, they get one alert (linked to the first matching search). The unique constraint on `[user_id, listing_id]` enforces this. +- **Null = no restriction.** A saved search with only `region: Manchester` matches any price, bedroom count, or property type in Manchester. +- **Criteria are deliberately limited** to what the task specifies. A price *range* (min + max), post code radius, or garden/parking flags are natural extensions but would be scope creep here. + +## Production considerations + +Matching currently runs synchronously inside the listener. This is appropriate for the exercise but would not scale to millions of saved searches. + +At production scale: + +1. `ListingBecameLive` would dispatch a **queued job**. +2. The job would **narrow candidates in SQL** using indexed criteria columns (`WHERE max_price IS NULL OR max_price >= ?`, etc.) rather than loading every saved search into memory. +3. Alerts would be **bulk-inserted** with `INSERT ... ON CONFLICT IGNORE` instead of per-row `firstOrCreate`. +4. A second job would **dispatch email/push notifications** asynchronously, respecting user preferences and digest frequency. + +The database uniqueness constraint makes alert generation safe to retry — a failed or duplicated job cannot produce duplicate alerts. + +## What I'd do with more time + +- **Mark-as-read endpoint** — the `read_at` column is in the schema but not yet exposed. +- **Notification delivery** — wire up Laravel notifications (database + mail channels) behind the alert creation. +- **Alert count in nav** — share an unread-alert count via the Inertia `HandleInertiaRequests` middleware. +- **Save-from-search UX** — a "Save this search" button on the listings index that pre-fills criteria from the current filters. +- **Richer Vue pages** — the saved-search and alert pages are functional stubs; a real UI would deserve more attention. + +## Tests (53 total) + +| Area | What's covered | +|------|----------------| +| **SavedSearch CRUD** | Create with full/partial criteria, validation (invalid type, negative price, missing criteria), delete own, 403 on another user's | +| **Matching** | Each criterion matching and not matching, boundary equality (price = max, bedrooms = min), null wildcards, partial criteria, mixed pass/fail | +| **Alert generation** | Draft→live creates alert, non-matching search skipped, draft doesn't trigger, editing already-live listing doesn't re-trigger, created-as-live triggers, duplicate dedup across overlapping searches, correct user scoping | +| **Alert viewing** | Index shows own alerts only, hides other users', includes nested listing + branch data, paginated, newest-first ordering | From 808241d28d54298558a5776fba56af8581296362 Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Tue, 6 Oct 2026 22:01:54 +0100 Subject: [PATCH 7/8] feat: remove unnecessary read_at column --- app/Http/Resources/AlertResource.php | 1 - app/Models/Alert.php | 11 ----------- database/factories/AlertFactory.php | 7 ------- .../2026_10_04_184703_create_alerts_table.php | 1 - tests/Feature/AlertPageTest.php | 1 - 5 files changed, 21 deletions(-) diff --git a/app/Http/Resources/AlertResource.php b/app/Http/Resources/AlertResource.php index 6bb0f2b..fd1cf46 100644 --- a/app/Http/Resources/AlertResource.php +++ b/app/Http/Resources/AlertResource.php @@ -20,7 +20,6 @@ public function toArray(Request $request): array 'id' => $this->id, 'listing' => new ListingResource($this->listing), 'saved_search' => new SavedSearchResource($this->savedSearch), - 'read_at' => $this->read_at?->toIso8601String(), 'created_at' => $this->created_at->toIso8601String(), ]; } diff --git a/app/Models/Alert.php b/app/Models/Alert.php index 8376336..cc08a43 100644 --- a/app/Models/Alert.php +++ b/app/Models/Alert.php @@ -13,7 +13,6 @@ * @property int $user_id * @property int $saved_search_id * @property int $listing_id - * @property Carbon|null $read_at * @property Carbon $created_at * @property Carbon $updated_at * @property-read User $user @@ -31,16 +30,6 @@ class Alert extends Model 'listing_id', ]; - /** - * @return array - */ - protected function casts(): array - { - return [ - 'read_at' => 'datetime', - ]; - } - /** * @return BelongsTo */ diff --git a/database/factories/AlertFactory.php b/database/factories/AlertFactory.php index 0c8275d..29a8d3f 100644 --- a/database/factories/AlertFactory.php +++ b/database/factories/AlertFactory.php @@ -26,11 +26,4 @@ public function definition(): array 'listing_id' => Listing::factory(), ]; } - - public function read(): static - { - return $this->state(fn () => [ - 'read_at' => now(), - ]); - } } diff --git a/database/migrations/2026_10_04_184703_create_alerts_table.php b/database/migrations/2026_10_04_184703_create_alerts_table.php index c2480da..2324ef7 100644 --- a/database/migrations/2026_10_04_184703_create_alerts_table.php +++ b/database/migrations/2026_10_04_184703_create_alerts_table.php @@ -13,7 +13,6 @@ public function up(): void $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('saved_search_id')->constrained()->cascadeOnDelete(); $table->foreignId('listing_id')->constrained()->cascadeOnDelete(); - $table->timestamp('read_at')->nullable(); $table->timestamps(); $table->index('saved_search_id'); diff --git a/tests/Feature/AlertPageTest.php b/tests/Feature/AlertPageTest.php index 9ff11bf..14dbc31 100644 --- a/tests/Feature/AlertPageTest.php +++ b/tests/Feature/AlertPageTest.php @@ -85,7 +85,6 @@ public function test_index_includes_listing_data_for_the_ui(): void ->has('alerts.data.0', fn (AssertableInertia $alert) => $alert ->has('id') ->has('created_at') - ->has('read_at') ->has('listing', fn (AssertableInertia $l) => $l ->where('id', $listing->id) ->where('price', 250_000) From 98bc0729405f286731667d858e2c732fac00fbfc Mon Sep 17 00:00:00 2001 From: Adam Moss Date: Tue, 6 Oct 2026 22:12:44 +0100 Subject: [PATCH 8/8] feat: added my own notes --- NOTES.md | 84 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/NOTES.md b/NOTES.md index 93d10c9..89a2cf5 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,57 +1,61 @@ -# Saved-Search Alerts — Implementation Notes +# Notes -## Architecture +## Events -A **SavedSearch** belongs to a User and stores search criteria as explicit database columns (`max_price`, `min_bedrooms`, `property_type`, `region`). Explicit columns were chosen over a JSON blob because the criteria are small, well-defined, and benefit from straightforward validation and queryability. +As someone who has worked with an entore EDA in a previous role I was very keen to make the alert generation dependent on a real event "ListingUpdated". This is based on a created() or updated() observer method by a listener called ListingObserver. The conditions are: -Matching logic lives on the model (`SavedSearch::matches(Listing): bool`), separate from any controller or HTTP concern. It mirrors the filter semantics already used by `ListingController`: `price <=`, `bedrooms >=`, exact match on type and region. Null criteria act as wildcards. +- Status _was_ changed +- Listing status is now "Live" +- The status before was NOT live -When a listing transitions to live, a `ListingObserver` dispatches a `ListingBecameLive` event. A `GenerateSavedSearchAlerts` listener handles the event: it iterates saved searches, applies `matches()`, and creates an **Alert** record for each user whose search is satisfied. Alerts are unique per user per listing (enforced by a database constraint), so the process is idempotent. +I also love events because in EDA this opens up the possibility in the future for other cool things to happen when the event occurs such as hooking in different notification types or triggering Analytics events. -``` -Listing saved (created as live, or status → live) - → ListingObserver dispatches ListingBecameLive - → GenerateSavedSearchAlerts listener - → iterates saved searches - → SavedSearch::matches(listing) - → Alert::firstOrCreate (deduplicated by user + listing) -``` +The requirement of the task mentioned “a listing became live” as a domain concept, so a domain event was the right fit for this. It's set up using `QUEUE_CONNECTION=sync` so the listener will do the job straight away, but this could easily become a queue job (SQS or Laravel Queue driver) and would certainly do this at scale. + +The resulting matches are persisted in the alerts table. If we later wanted email notifications, I'd probably dispatch separate queued jobs from newly created alerts, and track delivery separately from read_at, because read state and notification-delivery state are different concerns." + +## Models & Relationships + +True to the current system architecture I created a new model for "SavedSearch" and "Alert", a user can have many of each and both `belongTo` a User. -The observer checks `wasChanged('status')` and compares against the original value, so editing an already-live listing does not re-fire. +### SavedSearch -## Product decisions +I used separate columns for the search criteria because there are only four fields with clearly defined types. It also makes the SavedSearch data queryable, whereas a data column containing a JSON string would require much more processing when querying. -- **No backfill.** Alerts are only generated for listings that become live *after* a search is saved. Existing live listings are discoverable through normal search — flooding the alerts list on save would be noisy. -- **One alert per user per listing.** If a listing matches two of a user's saved searches, they get one alert (linked to the first matching search). The unique constraint on `[user_id, listing_id]` enforces this. -- **Null = no restriction.** A saved search with only `region: Manchester` matches any price, bedroom count, or property type in Manchester. -- **Criteria are deliberately limited** to what the task specifies. A price *range* (min + max), post code radius, or garden/parking flags are natural extensions but would be scope creep here. +### Alert -## Production considerations +An alert is a very simple model/DB structure with foreign keys for user ID, savedSearch ID and listing ID. An alert can never be duplicated because I protect it in two ways: firstOrCreate() checks for an existing user + listing combination, and there's also a DB constraint on user_id and listing_id. No PII in this table is a deliberate choice. -Matching currently runs synchronously inside the listener. This is appropriate for the exercise but would not scale to millions of saved searches. +Alerts are unique per user per listing (enforced by the DB constraint), so the process is idempotent. -At production scale: +## Tradeoffs & Decisions -1. `ListingBecameLive` would dispatch a **queued job**. -2. The job would **narrow candidates in SQL** using indexed criteria columns (`WHERE max_price IS NULL OR max_price >= ?`, etc.) rather than loading every saved search into memory. -3. Alerts would be **bulk-inserted** with `INSERT ... ON CONFLICT IGNORE` instead of per-row `firstOrCreate`. -4. A second job would **dispatch email/push notifications** asynchronously, respecting user preferences and digest frequency. +- No backfill. Alerts are only generated for listings that become live after a search is saved. Existing live listings are discoverable through normal search — flooding the alerts list on save would be noisy. +- One alert per user per listing. If a listing matches two of a user's saved searches, they get one alert (linked to the first matching search). The unique constraint on [user_id, listing_id] enforces this. +- Null = no restriction. A saved search with only region: Manchester matches any price, bedroom count, or property type in Manchester. +- Criteria are deliberately limited to what the task specifies. A price range (min + max) would be much better for users, post code radius instead of a hardcoded region, or garden/parking flags are natural extensions but would be scope creep here. -The database uniqueness constraint makes alert generation safe to retry — a failed or duplicated job cannot produce duplicate alerts. +One tradeoff is that this code doesn't deal with a listing being updated, only when it goes live. So if a house drops in value into someones criteria range it would not trigger the observer. This would be a potential improvement to make. + +The biggest risk to scaling is the SavedSearc::query()->cursor() which could potentially have millions of records, to run the matches() function against - this could run many times a day. I used matches() for this exercise for simplicity, but in a production environment would pass the matching logic to SQL, e.g: + +``` +SavedSearch::query() + ->where(fn ($q) => + $q->whereNull('max_price') + ->orWhere('max_price', '>=', $listing->price) + ) + // other criteria... + ->cursor(); +``` -## What I'd do with more time +## Alert processing — next steps -- **Mark-as-read endpoint** — the `read_at` column is in the schema but not yet exposed. -- **Notification delivery** — wire up Laravel notifications (database + mail channels) behind the alert creation. -- **Alert count in nav** — share an unread-alert count via the Inertia `HandleInertiaRequests` middleware. -- **Save-from-search UX** — a "Save this search" button on the listings index that pre-fills criteria from the current filters. -- **Richer Vue pages** — the saved-search and alert pages are functional stubs; a real UI would deserve more attention. +A second job would dispatch email/push notifications asynchronously, respecting user preferences and digest frequency. -## Tests (53 total) +The alerts table currently serves as both a record of matches and the user-facing alert list. In production it would also act as a staging area for delivery: -| Area | What's covered | -|------|----------------| -| **SavedSearch CRUD** | Create with full/partial criteria, validation (invalid type, negative price, missing criteria), delete own, 403 on another user's | -| **Matching** | Each criterion matching and not matching, boundary equality (price = max, bedrooms = min), null wildcards, partial criteria, mixed pass/fail | -| **Alert generation** | Draft→live creates alert, non-matching search skipped, draft doesn't trigger, editing already-live listing doesn't re-trigger, created-as-live triggers, duplicate dedup across overlapping searches, correct user scoping | -| **Alert viewing** | Index shows own alerts only, hides other users', includes nested listing + branch data, paginated, newest-first ordering | +1. A scheduled worker queries alerts that have not yet been delivered (a notified_at column or a separate alert_notifications table would track this). +2. The worker batches alerts per user into a digest to avoid spamming — the support team's concern ("don't spam people") is a real constraint. +3. Each batch is dispatched as a queued notification (Laravel's mail and/or database channels), decoupling delivery from generation so failures can be retried independently. +4. The database uniqueness constraint on [user_id, listing_id] means the generation side is idempotent, so a retried or duplicated job cannot produce duplicate alerts, so the worker only needs to track delivery state.