From e2b5a6620004b2c4eccef0766bbb5f7fe535239e Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 6 Sep 2026 11:26:50 +0100 Subject: [PATCH 01/26] Extract listing search criteria into a value object The rule for "does this listing match these criteria" lived inline in ListingController::index. Saved searches and the nightly alert run both need the same rule, so pull it out before adding either. ListingCriteria holds it once and exposes it three ways: applyTo() for querying listings, matches() for a listing already in hand, and rules() so the filter form and the saved-search form validate identically. applyTo() and matches() are separate implementations of one rule, so ListingCriteriaTest asserts directly that they select the same listings across a grid of boundary values rather than trusting them to agree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UtydwJw5gdxRWQHbJcBbro --- app/Http/Controllers/ListingController.php | 19 +-- app/Http/Requests/ListingIndexRequest.php | 17 ++- app/Search/ListingCriteria.php | 158 +++++++++++++++++++++ tests/Unit/ListingCriteriaTest.php | 134 +++++++++++++++++ 4 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 app/Search/ListingCriteria.php create mode 100644 tests/Unit/ListingCriteriaTest.php diff --git a/app/Http/Controllers/ListingController.php b/app/Http/Controllers/ListingController.php index 5f8ee48..e0ef891 100644 --- a/app/Http/Controllers/ListingController.php +++ b/app/Http/Controllers/ListingController.php @@ -21,24 +21,7 @@ public function index(ListingIndexRequest $request): Response { $query = Listing::query()->live(); - if ($request->filled('property_type')) { - $query->where('property_type', $request->string('property_type')); - } - - if ($request->filled('max_price')) { - $query->where('price', '<=', $request->integer('max_price')); - } - - if ($request->filled('min_bedrooms')) { - $query->where('bedrooms', '>=', $request->integer('min_bedrooms')); - } - - if ($request->filled('region')) { - $region = $request->string('region'); - $query->whereHas('branch', function ($branchQuery) use ($region) { - $branchQuery->where('region', $region); - }); - } + $request->criteria()->applyTo($query); // `id` is a tiebreaker: without it, listings sharing a `listed_at` can be // ordered differently between page requests, which duplicates or skips diff --git a/app/Http/Requests/ListingIndexRequest.php b/app/Http/Requests/ListingIndexRequest.php index 604c3d3..ccea228 100644 --- a/app/Http/Requests/ListingIndexRequest.php +++ b/app/Http/Requests/ListingIndexRequest.php @@ -2,9 +2,8 @@ namespace App\Http\Requests; -use App\Enums\PropertyType; +use App\Search\ListingCriteria; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rules\Enum; class ListingIndexRequest extends FormRequest { @@ -19,12 +18,18 @@ public function authorize(): bool public function rules(): array { return [ - 'property_type' => ['nullable', new Enum(PropertyType::class)], - 'max_price' => ['nullable', 'integer', 'min:0'], - 'min_bedrooms' => ['nullable', 'integer', 'min:0', 'max:20'], - 'region' => ['nullable', 'string', 'max:100'], + ...ListingCriteria::rules(), 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], 'page' => ['nullable', 'integer', 'min:1'], ]; } + + /** + * The filters this request is asking for, as the value object the rest of + * the application matches on. + */ + public function criteria(): ListingCriteria + { + return ListingCriteria::fromArray($this->validated()); + } } diff --git a/app/Search/ListingCriteria.php b/app/Search/ListingCriteria.php new file mode 100644 index 0000000..4b99913 --- /dev/null +++ b/app/Search/ListingCriteria.php @@ -0,0 +1,158 @@ + $attributes + */ + public static function fromArray(array $attributes): self + { + $type = self::blankToNull($attributes['property_type'] ?? null); + $maxPrice = self::blankToNull($attributes['max_price'] ?? null); + $minBedrooms = self::blankToNull($attributes['min_bedrooms'] ?? null); + + return new self( + propertyType: $type === null ? null : PropertyType::from($type), + maxPrice: $maxPrice === null ? null : (int) $maxPrice, + minBedrooms: $minBedrooms === null ? null : (int) $minBedrooms, + region: self::blankToNull($attributes['region'] ?? null), + ); + } + + /** + * Validation rules for the criteria fields, shared by the filter form and + * the saved-search form so the two cannot drift apart. + * + * @return array + */ + public static function rules(): array + { + return [ + 'property_type' => ['nullable', new Enum(PropertyType::class)], + 'max_price' => ['nullable', 'integer', 'min:0'], + 'min_bedrooms' => ['nullable', 'integer', 'min:0', 'max:20'], + 'region' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * Narrow a listings query to the listings these criteria match. + * + * @param Builder $query + */ + public function applyTo(Builder $query): void + { + if ($this->propertyType !== null) { + $query->where('property_type', $this->propertyType); + } + + if ($this->maxPrice !== null) { + $query->where('price', '<=', $this->maxPrice); + } + + if ($this->minBedrooms !== null) { + $query->where('bedrooms', '>=', $this->minBedrooms); + } + + if ($this->region !== null) { + $query->whereHas('branch', fn (Builder $branch) => $branch->where('region', $this->region)); + } + } + + /** + * The same rule as applyTo(), decided in PHP for a listing already in hand. + * + * Reads the branch relation, so eager-load it when checking many listings. + */ + public function matches(Listing $listing): bool + { + return ($this->propertyType === null || $listing->property_type === $this->propertyType) + && ($this->maxPrice === null || $listing->price <= $this->maxPrice) + && ($this->minBedrooms === null || $listing->bedrooms >= $this->minBedrooms) + && ($this->region === null || $listing->branch->region === $this->region); + } + + /** + * Criteria with nothing set match every listing. Worth naming, because that + * is a search we refuse to save rather than one we alert on nightly. + */ + public function isEmpty(): bool + { + return $this->toArray() === []; + } + + /** + * The query-string form — omits anything unset, so a link built from this + * carries only the filters that are actually in play. + * + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'property_type' => $this->propertyType?->value, + 'max_price' => $this->maxPrice, + 'min_bedrooms' => $this->minBedrooms, + 'region' => $this->region, + ], fn (string|int|null $value) => $value !== null); + } + + /** + * A short human label, for a saved search the buyer never named and for the + * alert digest. + */ + public function summary(): string + { + $parts = array_filter([ + $this->propertyType?->label(), + $this->region, + $this->minBedrooms === null ? null : "{$this->minBedrooms}+ beds", + $this->maxPrice === null ? null : 'up to £'.number_format($this->maxPrice), + ]); + + return $parts === [] ? 'All listings' : implode(' · ', $parts); + } + + /** + * Treats an omitted field and a submitted-but-empty one the same way: the + * filter form posts blanks for untouched fields. + */ + private static function blankToNull(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim((string) ($value instanceof PropertyType ? $value->value : $value)); + + return $value === '' ? null : $value; + } +} diff --git a/tests/Unit/ListingCriteriaTest.php b/tests/Unit/ListingCriteriaTest.php new file mode 100644 index 0000000..d3760d4 --- /dev/null +++ b/tests/Unit/ListingCriteriaTest.php @@ -0,0 +1,134 @@ + '', + 'max_price' => '', + 'min_bedrooms' => '', + 'region' => '', + ]); + + $this->assertTrue($blank->isEmpty(), 'A form of blanks is not a filter.'); + $this->assertTrue(ListingCriteria::fromArray([])->isEmpty()); + $this->assertSame([], $blank->toArray()); + } + + public function test_request_strings_are_cast_to_their_types(): void + { + $criteria = ListingCriteria::fromArray([ + 'property_type' => 'flat', + 'max_price' => '300000', + 'min_bedrooms' => '2', + 'region' => 'Leeds', + ]); + + $this->assertSame(PropertyType::Flat, $criteria->propertyType); + $this->assertSame(300_000, $criteria->maxPrice); + $this->assertSame(2, $criteria->minBedrooms); + $this->assertSame('Leeds', $criteria->region); + $this->assertFalse($criteria->isEmpty()); + } + + public function test_to_array_omits_unset_criteria_so_links_stay_clean(): void + { + $criteria = new ListingCriteria(maxPrice: 300_000, region: 'Leeds'); + + $this->assertSame(['max_price' => 300_000, 'region' => 'Leeds'], $criteria->toArray()); + } + + public function test_summary_describes_the_criteria(): void + { + $criteria = new ListingCriteria(PropertyType::Flat, 300_000, 2, 'Leeds'); + + $this->assertSame('Flat · Leeds · 2+ beds · up to £300,000', $criteria->summary()); + $this->assertSame('All listings', (new ListingCriteria)->summary()); + } + + /** + * applyTo() and matches() are two expressions of one rule, used in different + * places: the browse page filters in SQL, the nightly alert run checks a + * listing it already holds. If they ever disagree, a buyer is alerted about + * a property the site itself would not have shown them — so assert directly + * that they select the same listings. + */ + #[DataProvider('criteriaCases')] + public function test_the_query_and_the_predicate_select_the_same_listings(ListingCriteria $criteria): void + { + $listings = $this->listingGrid(); + + $query = Listing::query(); + $criteria->applyTo($query); + + $selectedByQuery = $query->orderBy('id')->pluck('id')->all(); + $selectedByPredicate = $listings + ->filter(fn (Listing $listing) => $criteria->matches($listing)) + ->sortBy('id') + ->pluck('id') + ->all(); + + // Guards against the degenerate pass where both sides match nothing. + $this->assertNotEmpty($selectedByQuery, 'Fixture should match at least one listing.'); + $this->assertSame($selectedByQuery, $selectedByPredicate); + } + + /** + * @return array + */ + public static function criteriaCases(): array + { + return [ + 'no criteria' => [new ListingCriteria], + 'property type' => [new ListingCriteria(propertyType: PropertyType::Flat)], + 'max price' => [new ListingCriteria(maxPrice: 300_000)], + 'min bedrooms' => [new ListingCriteria(minBedrooms: 3)], + 'region' => [new ListingCriteria(region: 'Leeds')], + 'type and region' => [new ListingCriteria(propertyType: PropertyType::Flat, region: 'Leeds')], + 'price and bedrooms' => [new ListingCriteria(maxPrice: 300_000, minBedrooms: 3)], + 'every criterion' => [new ListingCriteria(PropertyType::Flat, 300_000, 3, 'Leeds')], + ]; + } + + /** + * Every combination of the values the criteria discriminate on, so the two + * implementations are compared across boundaries rather than one happy path. + * + * @return Collection + */ + private function listingGrid(): Collection + { + $branches = collect(['Leeds', 'Manchester']) + ->map(fn (string $region) => Branch::factory()->create(['region' => $region])); + + foreach ($branches as $branch) { + foreach ([150_000, 300_000, 650_000] as $price) { + foreach ([1, 3] as $bedrooms) { + foreach ([PropertyType::Flat, PropertyType::Detached] as $type) { + Listing::factory()->live()->for($branch)->create([ + 'price' => $price, + 'bedrooms' => $bedrooms, + 'property_type' => $type, + ]); + } + } + } + } + + return Listing::query()->with('branch')->get(); + } +} From 46d6d8945504fe7ff5cf5b37f7b9010ea46bd6d3 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 6 Sep 2026 11:29:40 +0100 Subject: [PATCH 02/26] Add saved search and alert models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Criteria are stored as discrete nullable columns rather than a JSON blob so the nightly alert run can match listings against them in SQL, and so the values stay typed. SavedSearch::matching() is ListingCriteria::applyTo() inverted: given a listing, which searches want it. Region needs no join because the listing is already in hand. Its created_at clause is the backfill policy — you hear about listings that go live after you save a search, not the whole market — and SavedSearchTest asserts the inversion against the criteria it mirrors rather than trusting it by eye. The unique index on alerts (user_id, listing_id) is the anti-spam guarantee: one listing, one alert, however many searches matched it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UtydwJw5gdxRWQHbJcBbro --- app/Models/Alert.php | 85 ++++++++ app/Models/SavedSearch.php | 127 ++++++++++++ app/Models/User.php | 29 +++ database/factories/AlertFactory.php | 34 ++++ database/factories/SavedSearchFactory.php | 33 +++ ..._01_000003_create_saved_searches_table.php | 36 ++++ .../2024_01_01_000004_create_alerts_table.php | 39 ++++ tests/Feature/SavedSearchTest.php | 188 ++++++++++++++++++ 8 files changed, 571 insertions(+) create mode 100644 app/Models/Alert.php create mode 100644 app/Models/SavedSearch.php create mode 100644 database/factories/AlertFactory.php create mode 100644 database/factories/SavedSearchFactory.php create mode 100644 database/migrations/2024_01_01_000003_create_saved_searches_table.php create mode 100644 database/migrations/2024_01_01_000004_create_alerts_table.php create mode 100644 tests/Feature/SavedSearchTest.php diff --git a/app/Models/Alert.php b/app/Models/Alert.php new file mode 100644 index 0000000..dd1ab8b --- /dev/null +++ b/app/Models/Alert.php @@ -0,0 +1,85 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'listing_id', + 'saved_search_id', + 'read_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'read_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function listing(): BelongsTo + { + return $this->belongsTo(Listing::class); + } + + /** + * Null once the search that produced this alert has been deleted. + * + * @return BelongsTo + */ + public function savedSearch(): BelongsTo + { + return $this->belongsTo(SavedSearch::class); + } + + /** + * @param Builder $query + */ + public function scopeUnread(Builder $query): void + { + $query->whereNull('read_at'); + } +} diff --git a/app/Models/SavedSearch.php b/app/Models/SavedSearch.php new file mode 100644 index 0000000..7b9d608 --- /dev/null +++ b/app/Models/SavedSearch.php @@ -0,0 +1,127 @@ + $alerts + */ +class SavedSearch extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'name', + 'property_type', + 'max_price', + 'min_bedrooms', + 'region', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'property_type' => PropertyType::class, + 'max_price' => 'integer', + 'min_bedrooms' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function alerts(): HasMany + { + return $this->hasMany(Alert::class); + } + + /** + * The stored columns as the value object the rest of the application + * matches on. + */ + public function criteria(): ListingCriteria + { + return new ListingCriteria( + $this->property_type, + $this->max_price, + $this->min_bedrooms, + $this->region, + ); + } + + /** + * What to call this search in the UI. Most buyers won't name one, so fall + * back to describing what it looks for. + */ + public function label(): string + { + return $this->name ?? $this->criteria()->summary(); + } + + /** + * The searches that want this listing — ListingCriteria::applyTo() inverted, + * so one indexed query answers "who should hear about this?" rather than + * every saved search being loaded and checked in PHP. + * + * No join is needed for region: the listing is already in hand, so its + * branch region binds as a parameter. + * + * @param Builder $query + */ + public function scopeMatching(Builder $query, Listing $listing): void + { + // A listing that never went live alerts nobody. GenerateAlerts filters + // these out first, so this is a guard rather than a code path — but the + // safe answer for an alerting query is "nobody", not an error. + if ($listing->listed_at === null) { + $query->whereRaw('1 = 0'); + + return; + } + + $query + ->where(fn (Builder $q) => $q->whereNull('property_type')->orWhere('property_type', $listing->property_type)) + ->where(fn (Builder $q) => $q->whereNull('max_price')->orWhere('max_price', '>=', $listing->price)) + ->where(fn (Builder $q) => $q->whereNull('min_bedrooms')->orWhere('min_bedrooms', '<=', $listing->bedrooms)) + ->where(fn (Builder $q) => $q->whereNull('region')->orWhere('region', $listing->branch->region)) + // Only listings that went live after the search was saved. A new + // saved search asks to hear what comes next, not to be told about + // everything already on the market — the saved-searches page links + // straight to the browse results for that. + ->where('created_at', '<=', $listing->listed_at); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..a2703df 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,10 +6,19 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +/** + * @property int $id + * @property string $name + * @property string $email + * @property-read Collection $savedSearches + * @property-read Collection $alerts + */ #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable @@ -29,4 +38,24 @@ protected function casts(): array 'password' => 'hashed', ]; } + + /** + * Saved searches and alerts are always reached through their owner, so that + * scoping a query to the current user is structural rather than a `where` + * someone has to remember. + * + * @return HasMany + */ + public function savedSearches(): HasMany + { + return $this->hasMany(SavedSearch::class); + } + + /** + * @return HasMany + */ + public function alerts(): HasMany + { + return $this->hasMany(Alert::class); + } } diff --git a/database/factories/AlertFactory.php b/database/factories/AlertFactory.php new file mode 100644 index 0000000..8dc5d51 --- /dev/null +++ b/database/factories/AlertFactory.php @@ -0,0 +1,34 @@ + + */ +class AlertFactory extends Factory +{ + protected $model = Alert::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'listing_id' => Listing::factory()->live(), + 'saved_search_id' => null, + 'read_at' => null, + ]; + } + + public function read(): static + { + return $this->state(fn () => ['read_at' => now()]); + } +} diff --git a/database/factories/SavedSearchFactory.php b/database/factories/SavedSearchFactory.php new file mode 100644 index 0000000..c65f500 --- /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(), + 'name' => null, + 'property_type' => null, + 'max_price' => null, + 'min_bedrooms' => null, + 'region' => null, + ]; + } +} diff --git a/database/migrations/2024_01_01_000003_create_saved_searches_table.php b/database/migrations/2024_01_01_000003_create_saved_searches_table.php new file mode 100644 index 0000000..6c29e3b --- /dev/null +++ b/database/migrations/2024_01_01_000003_create_saved_searches_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + + // Optional — buyers rarely name a search, so the UI falls back to + // describing the criteria. See SavedSearch::label(). + $table->string('name')->nullable(); + + // The criteria, one column each rather than a JSON blob. The nightly + // alert run matches listings against these in SQL, which needs real + // columns; it also keeps the values typed for static analysis. + // Null means "any", matching ListingCriteria. + $table->string('property_type')->nullable(); + $table->unsignedInteger('max_price')->nullable(); + $table->unsignedTinyInteger('min_bedrooms')->nullable(); + $table->string('region')->nullable(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_searches'); + } +}; diff --git a/database/migrations/2024_01_01_000004_create_alerts_table.php b/database/migrations/2024_01_01_000004_create_alerts_table.php new file mode 100644 index 0000000..86a83d9 --- /dev/null +++ b/database/migrations/2024_01_01_000004_create_alerts_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('listing_id')->constrained()->cascadeOnDelete(); + + // Which search matched first, for display only. Kept when that search + // is deleted: an alert records something we already told the buyer + // about, and cascading would also delete alerts that a second, + // still-live search matched. + $table->foreignId('saved_search_id')->nullable()->constrained()->nullOnDelete(); + + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + // One listing, one alert per user, however many of their searches it + // matched. This is the anti-spam guarantee, so it lives in the schema + // rather than only in the code that writes alerts. + $table->unique(['user_id', 'listing_id']); + + // The alerts page lists one user's alerts, newest first. + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alerts'); + } +}; diff --git a/tests/Feature/SavedSearchTest.php b/tests/Feature/SavedSearchTest.php new file mode 100644 index 0000000..11fa8e3 --- /dev/null +++ b/tests/Feature/SavedSearchTest.php @@ -0,0 +1,188 @@ +create([ + 'property_type' => PropertyType::Flat, + 'max_price' => 300_000, + 'min_bedrooms' => 2, + 'region' => 'Leeds', + ]); + + $criteria = $search->fresh()->criteria(); + + $this->assertSame(PropertyType::Flat, $criteria->propertyType); + $this->assertSame(300_000, $criteria->maxPrice); + $this->assertSame(2, $criteria->minBedrooms); + $this->assertSame('Leeds', $criteria->region); + } + + public function test_label_falls_back_to_a_summary_of_the_criteria(): void + { + $named = SavedSearch::factory()->create(['name' => 'Somewhere near Mum']); + $unnamed = SavedSearch::factory()->create(['region' => 'Leeds', 'min_bedrooms' => 3]); + + $this->assertSame('Somewhere near Mum', $named->label()); + $this->assertSame('Leeds · 3+ beds', $unnamed->label()); + } + + public function test_matching_selects_only_the_searches_a_listing_satisfies(): void + { + $listing = $this->listing(region: 'Leeds'); + + $wants = SavedSearch::factory()->create(['region' => 'Leeds', 'max_price' => 300_000]); + $tooExpensive = SavedSearch::factory()->create(['max_price' => 200_000]); + $wrongRegion = SavedSearch::factory()->create(['region' => 'Manchester']); + $wrongType = SavedSearch::factory()->create(['property_type' => PropertyType::Bungalow]); + $anything = SavedSearch::factory()->create(); + + $this->assertEqualsCanonicalizing( + [$wants->id, $anything->id], + SavedSearch::query()->matching($listing)->pluck('id')->all(), + ); + + $this->assertNotContains($tooExpensive->id, SavedSearch::query()->matching($listing)->pluck('id')->all()); + $this->assertNotContains($wrongRegion->id, SavedSearch::query()->matching($listing)->pluck('id')->all()); + $this->assertNotContains($wrongType->id, SavedSearch::query()->matching($listing)->pluck('id')->all()); + } + + /** + * The backfill policy, expressed as one clause of the scope: saving a search + * asks to hear what comes next, not to be told about the whole market. + */ + public function test_matching_ignores_searches_saved_after_the_listing_went_live(): void + { + $listing = $this->listing(['listed_at' => now()->subWeek()]); + + $before = SavedSearch::factory()->create(['created_at' => now()->subMonth()]); + SavedSearch::factory()->create(['created_at' => now()]); + + $this->assertSame([$before->id], SavedSearch::query()->matching($listing)->pluck('id')->all()); + } + + public function test_a_listing_that_never_went_live_matches_nothing(): void + { + $listing = $this->listing(['status' => 'draft', 'listed_at' => null]); + SavedSearch::factory()->create(); + + $this->assertSame([], SavedSearch::query()->matching($listing)->pluck('id')->all()); + } + + /** + * SavedSearch::matching() is ListingCriteria::applyTo() turned inside out — + * a third expression of the same rule, and the one the alert run depends on. + * Assert it against the criteria it was built from rather than trusting the + * inversion to have been done correctly by eye. + */ + public function test_matching_agrees_with_the_criteria_it_was_built_from(): void + { + $searches = $this->savedSearchGrid(); + $matchedAnything = false; + + foreach ($this->listingGrid() as $listing) { + $bySql = SavedSearch::query()->matching($listing)->orderBy('id')->pluck('id')->all(); + + $byCriteria = $searches + ->filter(fn (SavedSearch $search) => $search->criteria()->matches($listing)) + ->sortBy('id') + ->pluck('id') + ->all(); + + $this->assertSame($bySql, $byCriteria, "Disagreement on listing {$listing->id}."); + + $matchedAnything = $matchedAnything || $bySql !== []; + } + + $this->assertTrue($matchedAnything, 'Fixture should produce at least one match.'); + } + + /** + * Every combination of "set" and "any" across the four criteria. + * + * @return Collection + */ + private function savedSearchGrid(): Collection + { + $user = User::factory()->create(); + + foreach ([null, PropertyType::Flat] as $type) { + foreach ([null, 300_000] as $maxPrice) { + foreach ([null, 3] as $minBedrooms) { + foreach ([null, 'Leeds'] as $region) { + SavedSearch::factory()->for($user)->create([ + 'property_type' => $type, + 'max_price' => $maxPrice, + 'min_bedrooms' => $minBedrooms, + 'region' => $region, + // Comfortably before every listing below goes live, + // so the created_at clause isn't what's under test. + 'created_at' => now()->subYear(), + ]); + } + } + } + } + + return SavedSearch::all(); + } + + /** + * @return Collection + */ + private function listingGrid(): Collection + { + $branches = collect(['Leeds', 'Manchester']) + ->map(fn (string $region) => Branch::factory()->create(['region' => $region])); + + foreach ($branches as $branch) { + foreach ([150_000, 300_000, 650_000] as $price) { + foreach ([1, 3] as $bedrooms) { + foreach ([PropertyType::Flat, PropertyType::Detached] as $type) { + Listing::factory()->live()->for($branch)->create([ + 'price' => $price, + 'bedrooms' => $bedrooms, + 'property_type' => $type, + 'listed_at' => now(), + ]); + } + } + } + } + + return Listing::query()->with('branch')->get(); + } + + /** + * @param array $attributes + */ + private function listing(array $attributes = [], string $region = 'Leeds'): Listing + { + $branch = Branch::factory()->create(['region' => $region]); + + // Every field a saved search discriminates on is pinned, so a test only + // varies what it is actually about. The factory randomises property_type + // otherwise, which makes any assertion about type intermittent. + return Listing::factory()->live()->for($branch)->create([ + 'listed_at' => now(), + 'property_type' => PropertyType::Terraced, + 'price' => 250_000, + 'bedrooms' => 3, + ...$attributes, + ]); + } +} From 5c1635e217d0df63128a34b3afae8e66aaf5fa6a Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 6 Sep 2026 11:33:25 +0100 Subject: [PATCH 03/26] Generate and deliver alerts on a nightly schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerts are produced by a scheduled command rather than an event fired when a listing goes live, because delivery has to be batched — twenty listings published across a Tuesday must not be twenty interruptions. The alert records stay granular so the alerts page can list properties individually; only delivery collapses, into one digest per buyer per run. Batching also filters out churn: a listing that goes live and sells before the run is never in the result set, so we don't advertise a property that has already gone. The run selects on the absence of an alert rather than on a time window, so it is idempotent — a missed day is caught up by the next run instead of being lost, and running it by hand to demo the feature is safe. The seven-day window only bounds the scan. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UtydwJw5gdxRWQHbJcBbro --- app/Alerts/GenerateAlerts.php | 93 +++++++++ app/Console/Commands/DispatchAlerts.php | 51 +++++ app/Notifications/NewListingMatches.php | 83 +++++++++ routes/console.php | 9 + tests/Feature/DispatchAlertsCommandTest.php | 127 +++++++++++++ tests/Feature/GenerateAlertsTest.php | 197 ++++++++++++++++++++ 6 files changed, 560 insertions(+) create mode 100644 app/Alerts/GenerateAlerts.php create mode 100644 app/Console/Commands/DispatchAlerts.php create mode 100644 app/Notifications/NewListingMatches.php create mode 100644 tests/Feature/DispatchAlertsCommandTest.php create mode 100644 tests/Feature/GenerateAlertsTest.php diff --git a/app/Alerts/GenerateAlerts.php b/app/Alerts/GenerateAlerts.php new file mode 100644 index 0000000..d6e9895 --- /dev/null +++ b/app/Alerts/GenerateAlerts.php @@ -0,0 +1,93 @@ + the alerts created by this run + */ + public function __invoke(): Collection + { + /** @var Collection $created */ + $created = new Collection; + + Listing::query() + ->live() + ->whereNotNull('listed_at') + ->where('listed_at', '>=', now()->subDays(self::RECOVERY_WINDOW_DAYS)) + // matching() reads the branch region for every listing. + ->with('branch') + ->orderBy('id') + ->each(function (Listing $listing) use ($created): void { + foreach ($this->searchesAwaitingAlert($listing) as $search) { + $alert = Alert::query()->firstOrCreate( + ['user_id' => $search->user_id, 'listing_id' => $listing->id], + ['saved_search_id' => $search->id], + ); + + if ($alert->wasRecentlyCreated) { + $created->push($alert); + } + } + }); + + return $created; + } + + /** + * The saved searches this listing should alert on — at most one per user. + * + * A listing matching two of someone's searches is still one alert; the + * oldest matching search is credited, so the attribution is stable between + * runs. The unique index on alerts enforces the same rule in the database, + * and firstOrCreate above makes a re-run a no-op rather than an error. + * + * @return Collection + */ + private function searchesAwaitingAlert(Listing $listing): Collection + { + return SavedSearch::query() + ->matching($listing) + // Skip anyone already alerted about this listing, so a re-run inside + // the recovery window costs one query rather than a write per search. + ->whereNotExists(fn (QueryBuilder $alerts) => $alerts + ->select(DB::raw(1)) + ->from('alerts') + ->whereColumn('alerts.user_id', 'saved_searches.user_id') + ->where('alerts.listing_id', $listing->id)) + ->orderBy('id') + ->get() + ->unique('user_id'); + } +} diff --git a/app/Console/Commands/DispatchAlerts.php b/app/Console/Commands/DispatchAlerts.php new file mode 100644 index 0000000..9af11fb --- /dev/null +++ b/app/Console/Commands/DispatchAlerts.php @@ -0,0 +1,51 @@ +isEmpty()) { + $this->info('No new matches.'); + + return self::SUCCESS; + } + + $alerts->load('listing.branch', 'user'); + + /** @var \Illuminate\Support\Collection> $byUser */ + $byUser = $alerts->groupBy('user_id'); + + foreach ($byUser as $theirs) { + /** @var User $user */ + $user = $theirs->first()->user; + + // One message per buyer per run, covering every match. Sending per + // alert here would undo the reason this runs on a schedule at all. + $user->notify(new NewListingMatches($theirs)); + } + + $this->info(sprintf( + '%d alert(s) across %d listing(s); %d digest(s) sent.', + $alerts->count(), + $alerts->unique('listing_id')->count(), + $byUser->count(), + )); + + return self::SUCCESS; + } +} diff --git a/app/Notifications/NewListingMatches.php b/app/Notifications/NewListingMatches.php new file mode 100644 index 0000000..d04cb59 --- /dev/null +++ b/app/Notifications/NewListingMatches.php @@ -0,0 +1,83 @@ + $alerts + */ + public function __construct(private readonly Collection $alerts) {} + + /** + * @return list + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $count = $this->alerts->count(); + + $message = (new MailMessage) + ->subject($count === 1 + ? 'A new property matches your saved search' + : "{$count} new properties match your saved searches") + ->line($count === 1 + ? 'A property has come onto the market matching a search you saved:' + : "{$count} properties have come onto the market matching searches you saved:"); + + foreach ($this->alerts->take(self::PREVIEW) as $alert) { + $message->line($this->describe($alert)); + } + + if ($count > self::PREVIEW) { + $message->line('…and '.($count - self::PREVIEW).' more.'); + } + + return $message + ->action('See all your alerts', url('/alerts')) + ->line('You are getting this because you saved a search. Delete it to stop these.'); + } + + private function describe(Alert $alert): string + { + $listing = $alert->listing; + + return sprintf( + '£%s — %s, %s (%d bed %s)', + number_format($listing->price), + $listing->address_line_1, + $listing->city, + $listing->bedrooms, + strtolower($listing->property_type->label()), + ); + } +} diff --git a/routes/console.php b/routes/console.php index 3c9adf1..e03cb86 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,16 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Alerts are generated and delivered once a day rather than as listings go +// live, so a buyer gets one digest instead of a message per property. The +// evening slot means a day's listings arrive together. +// +// The command is idempotent, so running it by hand to demonstrate the feature +// is safe — see the README. +Schedule::command('alerts:dispatch')->dailyAt('18:00'); diff --git a/tests/Feature/DispatchAlertsCommandTest.php b/tests/Feature/DispatchAlertsCommandTest.php new file mode 100644 index 0000000..efa9368 --- /dev/null +++ b/tests/Feature/DispatchAlertsCommandTest.php @@ -0,0 +1,127 @@ +create(); + $this->savedSearch($user, ['region' => 'Leeds']); + + $branch = Branch::factory()->create(['region' => 'Leeds']); + $this->liveListings($branch, 5); + + $this->artisan('alerts:dispatch')->assertSuccessful(); + + $this->assertDatabaseCount('alerts', 5); + Notification::assertSentToTimes($user, NewListingMatches::class, 1); + } + + public function test_the_digest_covers_every_match_in_the_run(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $this->savedSearch($user, ['region' => 'Leeds']); + $this->liveListings(Branch::factory()->create(['region' => 'Leeds']), 3); + + $this->artisan('alerts:dispatch')->assertSuccessful(); + + Notification::assertSentTo( + $user, + NewListingMatches::class, + fn (NewListingMatches $digest, array $channels, User $notifiable) => $digest + ->toMail($notifiable) + ->subject === '3 new properties match your saved searches', + ); + } + + public function test_each_buyer_is_told_only_about_their_own_matches(): void + { + Notification::fake(); + + $leedsBuyer = User::factory()->create(); + $manchesterBuyer = User::factory()->create(); + $this->savedSearch($leedsBuyer, ['region' => 'Leeds']); + $this->savedSearch($manchesterBuyer, ['region' => 'Manchester']); + + $this->liveListings(Branch::factory()->create(['region' => 'Leeds']), 2); + + $this->artisan('alerts:dispatch')->assertSuccessful(); + + Notification::assertSentToTimes($leedsBuyer, NewListingMatches::class, 1); + Notification::assertNotSentTo($manchesterBuyer, NewListingMatches::class); + } + + public function test_a_quiet_day_sends_nothing(): void + { + Notification::fake(); + + $this->savedSearch(User::factory()->create(), ['region' => 'Leeds']); + + $this->artisan('alerts:dispatch') + ->expectsOutputToContain('No new matches.') + ->assertSuccessful(); + + Notification::assertNothingSent(); + } + + /** + * Re-running is how the schedule catches up after a missed day, so it must + * not mean telling everyone twice. + */ + public function test_re_running_does_not_alert_anyone_again(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $this->savedSearch($user, ['region' => 'Leeds']); + $this->liveListings(Branch::factory()->create(['region' => 'Leeds']), 2); + + $this->artisan('alerts:dispatch')->assertSuccessful(); + $this->artisan('alerts:dispatch')->assertSuccessful(); + + $this->assertDatabaseCount('alerts', 2); + Notification::assertSentToTimes($user, NewListingMatches::class, 1); + } + + /** + * @param array $attributes + */ + private function savedSearch(User $user, array $attributes = []): SavedSearch + { + return SavedSearch::factory()->for($user)->create([ + 'created_at' => now()->subMonth(), + ...$attributes, + ]); + } + + private function liveListings(Branch $branch, int $count): void + { + Listing::factory($count)->live()->for($branch)->create([ + 'property_type' => PropertyType::Terraced, + 'price' => 250_000, + 'bedrooms' => 3, + 'listed_at' => now()->subHours(2), + ]); + } +} diff --git a/tests/Feature/GenerateAlertsTest.php b/tests/Feature/GenerateAlertsTest.php new file mode 100644 index 0000000..ae1add9 --- /dev/null +++ b/tests/Feature/GenerateAlertsTest.php @@ -0,0 +1,197 @@ +create(); + $search = $this->savedSearch($user, ['region' => 'Leeds', 'max_price' => 300_000]); + $listing = $this->liveListing(); + + $created = $this->generate(); + + $this->assertCount(1, $created); + $this->assertDatabaseHas('alerts', [ + 'user_id' => $user->id, + 'listing_id' => $listing->id, + 'saved_search_id' => $search->id, + 'read_at' => null, + ]); + } + + public function test_it_does_not_alert_when_the_listing_misses_the_criteria(): void + { + $this->savedSearch(User::factory()->create(), ['max_price' => 200_000]); + $this->liveListing(['price' => 250_000]); + + $this->generate(); + + $this->assertDatabaseCount('alerts', 0); + } + + public function test_every_buyer_with_a_matching_search_is_alerted(): void + { + $this->savedSearch(User::factory()->create(), ['region' => 'Leeds']); + $this->savedSearch(User::factory()->create(), ['min_bedrooms' => 2]); + $this->savedSearch(User::factory()->create(), ['region' => 'Manchester']); + + $this->liveListing(); + + $this->assertCount(2, $this->generate()); + $this->assertDatabaseCount('alerts', 2); + } + + /** + * The browse pages only expose live listings, so alerts must not become a + * way to hear about a draft — or about something already sold. + */ + #[DataProvider('nonLiveStates')] + public function test_it_only_alerts_on_live_listings(string $state): void + { + $this->savedSearch(User::factory()->create()); + + // listed_at is forced recent so that status is the only thing keeping + // this listing out of the run. + Listing::factory()->{$state}()->for(Branch::factory()->create())->create([ + 'listed_at' => now()->subHour(), + ]); + + $this->generate(); + + $this->assertDatabaseCount('alerts', 0); + } + + /** + * @return array + */ + public static function nonLiveStates(): array + { + return [ + 'draft' => ['draft'], + 'under offer' => ['underOffer'], + 'sold' => ['sold'], + ]; + } + + /** + * Support's constraint, at the record level: matching two of someone's + * searches is still one property, so it is still one alert. + */ + public function test_a_listing_matching_two_of_a_buyers_searches_is_one_alert(): void + { + $user = User::factory()->create(); + $first = $this->savedSearch($user, ['region' => 'Leeds']); + $this->savedSearch($user, ['max_price' => 300_000]); + + $this->liveListing(); + + $this->assertCount(1, $this->generate()); + $this->assertDatabaseCount('alerts', 1); + + // Attribution is stable rather than arbitrary: the older search wins. + $this->assertDatabaseHas('alerts', ['saved_search_id' => $first->id]); + } + + public function test_running_again_creates_nothing_further(): void + { + $this->savedSearch(User::factory()->create(), ['region' => 'Leeds']); + $this->liveListing(); + + $this->assertCount(1, $this->generate()); + $this->assertCount(0, $this->generate(), 'A second run must be a no-op.'); + $this->assertDatabaseCount('alerts', 1); + } + + /** + * The backfill decision: saving a search asks to hear what comes next. The + * saved-searches page links to the browse results for what is already live. + */ + public function test_it_ignores_listings_that_went_live_before_the_search_was_saved(): void + { + $this->savedSearch(User::factory()->create(), ['created_at' => now()->subHour()]); + $this->liveListing(['listed_at' => now()->subDay()]); + + $this->generate(); + + $this->assertDatabaseCount('alerts', 0); + } + + /** + * The run selects on the absence of an alert, not on "published today", so + * a day the job didn't run is caught up rather than lost. + */ + public function test_a_missed_run_is_picked_up_by_the_next_one(): void + { + $this->savedSearch(User::factory()->create(), ['region' => 'Leeds']); + $this->liveListing(['listed_at' => now()->subDays(6)]); + + $this->assertCount(1, $this->generate()); + } + + public function test_it_stops_looking_at_the_edge_of_the_recovery_window(): void + { + $this->savedSearch(User::factory()->create(), ['created_at' => now()->subYear()]); + $this->liveListing([ + 'listed_at' => now()->subDays(GenerateAlerts::RECOVERY_WINDOW_DAYS + 1), + ]); + + $this->generate(); + + $this->assertDatabaseCount('alerts', 0); + } + + /** + * @return Collection + */ + private function generate(): Collection + { + return app(GenerateAlerts::class)(); + } + + /** + * @param array $attributes + */ + private function savedSearch(User $user, array $attributes = []): SavedSearch + { + return SavedSearch::factory()->for($user)->create([ + 'created_at' => now()->subMonth(), + ...$attributes, + ]); + } + + /** + * Pins every field a search discriminates on, so each test varies only what + * it is about. + * + * @param array $attributes + */ + private function liveListing(array $attributes = [], string $region = 'Leeds'): Listing + { + return Listing::factory() + ->live() + ->for(Branch::factory()->create(['region' => $region])) + ->create([ + 'property_type' => PropertyType::Terraced, + 'price' => 250_000, + 'bedrooms' => 3, + 'listed_at' => now()->subHours(2), + ...$attributes, + ]); + } +} From a543d0142ade604766c1217324ae86c4143bd633 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 6 Sep 2026 11:36:40 +0100 Subject: [PATCH 04/26] Let buyers create, view and delete saved searches The criteria validation rules are shared with the browse filters, so a search can only be saved for something the site can actually search for. An empty search is refused: it would match every new listing, which is the one thing Support asked us not to build. Searches are always reached through their owner rather than looked up by id and then checked, so an unscoped query cannot be written by accident and another buyer's search is a 404 by construction. That is why there is no policy. Saving does not backfill alerts. The saved-searches page links each search to the browse results for its criteria instead, so "what matches right now" is one click away while alerts stay meaningful as "new since I asked". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UtydwJw5gdxRWQHbJcBbro --- app/Http/Controllers/Controller.php | 21 ++- .../Controllers/SavedSearchController.php | 61 ++++++++ app/Http/Middleware/HandleInertiaRequests.php | 4 + app/Http/Requests/StoreSavedSearchRequest.php | 57 +++++++ app/Http/Resources/SavedSearchResource.php | 29 ++++ resources/js/components/AppLayout.vue | 50 +++++- resources/js/components/ListingFilters.vue | 3 +- resources/js/pages/Listings/Index.vue | 44 ++++-- resources/js/pages/SavedSearches/Index.vue | 135 ++++++++++++++++ routes/web.php | 5 + tests/Feature/SavedSearchPageTest.php | 148 ++++++++++++++++++ 11 files changed, 538 insertions(+), 19 deletions(-) create mode 100644 app/Http/Controllers/SavedSearchController.php create mode 100644 app/Http/Requests/StoreSavedSearchRequest.php create mode 100644 app/Http/Resources/SavedSearchResource.php create mode 100644 resources/js/pages/SavedSearches/Index.vue create mode 100644 tests/Feature/SavedSearchPageTest.php diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..0fa77bc 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,26 @@ namespace App\Http\Controllers; +use App\Models\User; +use Illuminate\Http\Request; + abstract class Controller { - // + /** + * The buyer this request belongs to. + * + * Auth is stubbed (see the README): the resolver returns null until the + * database is seeded, which is the same shape as a genuinely + * unauthenticated request. User-scoped pages should refuse that rather + * than fail on a null — and returning a non-nullable User keeps every + * caller honest about scoping its queries. + */ + protected function buyer(Request $request): User + { + $user = $request->user(); + + abort_if($user === null, 403, 'This page needs a signed-in user.'); + + return $user; + } } diff --git a/app/Http/Controllers/SavedSearchController.php b/app/Http/Controllers/SavedSearchController.php new file mode 100644 index 0000000..3acde67 --- /dev/null +++ b/app/Http/Controllers/SavedSearchController.php @@ -0,0 +1,61 @@ +buyer($request) + ->savedSearches() + ->withCount('alerts') + ->latest('id') + ->get(); + + return Inertia::render('SavedSearches/Index', [ + 'savedSearches' => SavedSearchResource::collection($savedSearches), + 'branches' => BranchResource::collection(Branch::query()->orderBy('name')->get()), + 'propertyTypes' => PropertyType::options(), + ]); + } + + public function store(StoreSavedSearchRequest $request): RedirectResponse + { + $this->buyer($request)->savedSearches()->create([ + 'name' => $request->input('name'), + ...$request->criteria()->toArray(), + ]); + + // Deliberately no backfill: this search will alert on listings that go + // live from now on. Everything already on the market is one click away + // on the saved-searches page, which links to these criteria in the + // browse results. + return back()->with('flash', "Search saved. We'll email you when something new matches."); + } + + /** + * Resolved through the relationship rather than by route-model binding, so + * another buyer's search is a 404 by construction. + */ + public function destroy(Request $request, string $savedSearch): RedirectResponse + { + $this->buyer($request)->savedSearches()->findOrFail($savedSearch)->delete(); + + return back()->with('flash', 'Search deleted.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 4f3b1b1..a1a8532 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -40,6 +40,10 @@ public function share(Request $request): array 'auth' => [ 'user' => $request->user()?->only('id', 'name', 'email'), ], + + // One-off confirmations after a redirect ("Search saved."), read + // lazily so pages that never show one don't touch the session. + 'flash' => fn () => $request->session()->get('flash'), ]; } } diff --git a/app/Http/Requests/StoreSavedSearchRequest.php b/app/Http/Requests/StoreSavedSearchRequest.php new file mode 100644 index 0000000..ef93948 --- /dev/null +++ b/app/Http/Requests/StoreSavedSearchRequest.php @@ -0,0 +1,57 @@ + + */ + public function rules(): array + { + return [ + ...ListingCriteria::rules(), + 'name' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + // A search with no criteria matches every new listing, which is + // the one thing Support asked us not to build. The field list + // comes from the criteria rules so it cannot drift. + $hasCriterion = collect(array_keys(ListingCriteria::rules())) + ->contains(fn (string $field) => filled($this->input($field))); + + if (! $hasCriterion) { + $validator->errors()->add( + 'criteria', + 'Choose at least one filter — an empty search would alert you about every new listing.', + ); + } + }, + ]; + } + + public function criteria(): ListingCriteria + { + return ListingCriteria::fromArray($this->validated()); + } +} diff --git a/app/Http/Resources/SavedSearchResource.php b/app/Http/Resources/SavedSearchResource.php new file mode 100644 index 0000000..c527df0 --- /dev/null +++ b/app/Http/Resources/SavedSearchResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'label' => $this->label(), + // The criteria as a query string, so the page can link straight to + // the browse results for this search. + 'criteria' => $this->criteria()->toArray(), + 'alerts_count' => $this->whenCounted('alerts'), + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/resources/js/components/AppLayout.vue b/resources/js/components/AppLayout.vue index a0625e7..6d342fa 100644 --- a/resources/js/components/AppLayout.vue +++ b/resources/js/components/AppLayout.vue @@ -1,25 +1,61 @@