Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Notes

## Events

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:

- Status _was_ changed
- Listing status is now "Live"
- The status before was NOT live

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.

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.

### SavedSearch

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.

### Alert

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.

Alerts are unique per user per listing (enforced by the DB constraint), so the process is idempotent.

## Tradeoffs & 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) 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.

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();
```

## Alert processing — next steps

A second job would dispatch email/push notifications asynchronously, respecting user preferences and digest frequency.

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:

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.
14 changes: 14 additions & 0 deletions app/Events/ListingBecameLive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Events;

use App\Models\Listing;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ListingBecameLive
{
use Dispatchable, SerializesModels;

public function __construct(public Listing $listing) {}
}
24 changes: 24 additions & 0 deletions app/Http/Controllers/AlertController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Http\Controllers;

use App\Http\Resources\AlertResource;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class AlertController extends Controller
{
public function index(Request $request): Response
{
$alerts = $request->user()
->alerts()
->with('listing.branch', 'savedSearch')
->latest()
->paginate(15);

return Inertia::render('Alerts/Index', [
'alerts' => AlertResource::collection($alerts),
]);
}
}
50 changes: 50 additions & 0 deletions app/Http/Controllers/SavedSearchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Http\Controllers;

use App\Enums\PropertyType;
use App\Http\Requests\SavedSearchRequest;
use App\Http\Resources\SavedSearchResource;
use App\Models\Branch;
use App\Models\SavedSearch;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class SavedSearchController extends Controller
{
public function index(Request $request): Response
{
$searches = $request->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');
}
}
46 changes: 46 additions & 0 deletions app/Http/Requests/SavedSearchRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace App\Http\Requests;

use App\Enums\PropertyType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Validator;

class SavedSearchRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'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'],
];
}

/**
* 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.');
}
});
}
}
26 changes: 26 additions & 0 deletions app/Http/Resources/AlertResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Http\Resources;

use App\Models\Alert;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* @mixin Alert
*/
class AlertResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'listing' => new ListingResource($this->listing),
'saved_search' => new SavedSearchResource($this->savedSearch),
'created_at' => $this->created_at->toIso8601String(),
];
}
}
29 changes: 29 additions & 0 deletions app/Http/Resources/SavedSearchResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Http\Resources;

use App\Models\SavedSearch;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* @mixin SavedSearch
*/
class SavedSearchResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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(),
];
}
}
31 changes: 31 additions & 0 deletions app/Listeners/GenerateSavedSearchAlerts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Listeners;

use App\Events\ListingBecameLive;
use App\Models\Alert;
use App\Models\SavedSearch;
use Illuminate\Contracts\Queue\ShouldQueue;

class GenerateSavedSearchAlerts implements ShouldQueue
{
public function handle(ListingBecameLive $event): void
{
$listing = $event->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,
],
);
});
}
}
56 changes: 56 additions & 0 deletions app/Models/Alert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace App\Models;

use Database\Factories\AlertFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
* @property int $id
* @property int $user_id
* @property int $saved_search_id
* @property int $listing_id
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read User $user
* @property-read SavedSearch $savedSearch
* @property-read Listing $listing
*/
class Alert extends Model
{
/** @use HasFactory<AlertFactory> */
use HasFactory;

protected $fillable = [
'user_id',
'saved_search_id',
'listing_id',
];

/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

/**
* @return BelongsTo<SavedSearch, $this>
*/
public function savedSearch(): BelongsTo
{
return $this->belongsTo(SavedSearch::class);
}

/**
* @return BelongsTo<Listing, $this>
*/
public function listing(): BelongsTo
{
return $this->belongsTo(Listing::class);
}
}
3 changes: 3 additions & 0 deletions app/Models/Listing.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +33,7 @@
* @property Carbon $updated_at
* @property-read Branch $branch
*/
#[ObservedBy(ListingObserver::class)]
class Listing extends Model
{
/** @use HasFactory<ListingFactory> */
Expand Down
Loading