diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md new file mode 100755 index 0000000..6a534fd --- /dev/null +++ b/.claude/skills/create-pr/SKILL.md @@ -0,0 +1,65 @@ +--- +name: create-pr +description: Use when the user asks to create a pull request, open a PR, or ship their current branch for review. Handles PR creation, Jira linking, Copilot review, and stacking large PRs. +--- + +# Create Pull Request + +Follow these steps in order. Do not skip the size check — it comes before +opening the PR, not after. + +## 1. Review the diff and check size + +Run `git diff main...HEAD --stat` (or the relevant base branch) to see +what's changed. + +If the diff is large or touches multiple unrelated concerns, **stop and +propose a stack** instead of one PR: break the branch into a sequence of +smaller branches/PRs, each depending on the previous one, each reviewable +on its own (e.g. "schema + migration" → "backend logic" → "frontend"). +Describe the proposed stack to the user and confirm before restructuring +history. Only proceed to a single PR once the change is a sensible, +reviewable unit. + +## 2. Ask for the Jira ticket link + +Before creating the PR, ask the user for the Jira ticket link if one +hasn't been provided. Don't guess a ticket number or invent one. + +## 3. Write the PR description + +Summarise the actual changes (not a commit-by-commit log) — what changed, +why, and anything a reviewer should pay attention to. Include the Jira +link in the description (e.g. under a "Ticket:" line). + +## 4. Create and mark ready for review + +gh pr create --title "" --body "" --base main +gh pr ready # only if it was opened as a draft + + +Confirm the PR is not left in draft state unless the user asked for that. + +## 5. Request a Copilot review + +gh pr edit --add-reviewer @copilot + + +Wait for Copilot's review to post. + +## 6. Triage Copilot's suggestions + +For each suggestion Copilot makes: + +- **If you agree** — apply the fix, commit, and push. +- **If you disagree** — do not apply it. Instead, reply on the PR (or + summarise to the user) explaining specifically why the suggestion is + wrong or not worth taking, so the user can make the final call. Never + silently ignore a suggestion — every one gets either fixed or explained. + +## 7. Watch for further activity + +After the above, continue monitoring the PR for new commits, new Copilot +review rounds triggered by pushes, or human reviewer comments, and repeat +step 6's triage for anything new that comes in — until the user says +they're done or the PR is merged. diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md new file mode 100755 index 0000000..8b03297 --- /dev/null +++ b/.claude/skills/grill-me/SKILL.md @@ -0,0 +1,49 @@ +--- +name: grill-me +description: Use before starting any non-trivial coding task — a new feature, a refactor, a bug fix with unclear scope, or any instruction that leaves room for interpretation. Runs a short clarifying interview before writing any code. +--- + +# Grill Me + +Before doing any work on an instruction, clarify it first. Do not start +writing code, running commands, or making changes until this process is +complete and the developer has confirmed alignment. + +## Rules + +- **Never silently assume.** Anywhere the instruction is ambiguous, + underspecified, or has more than one reasonable interpretation, ask. +- **Don't just ask open questions.** For each point of ambiguity, give a + short list of sensible options and **state your recommendation** with a + one-line reason — the developer should be able to just say "go with + your suggestion" rather than having to design the answer themselves. +- **Leave room for more detail.** After presenting the questions/options, + explicitly invite the developer to add context, correct an assumption, + or explain further — don't treat the first answer as necessarily final. +- **Iterate, don't loop forever.** Ask what's still unclear after each + round of answers. Stop once there's nothing left that would change how + you'd implement it — don't manufacture extra questions for their own + sake once genuine ambiguity is resolved. + +## Process + +1. Read the instruction. Identify every point where a reasonable + implementation could go more than one way (scope, edge cases, data + shape, UX behaviour, what "done" means, error handling, naming, + architecture choices that aren't dictated by existing patterns). +2. For each point, present it as: the question, 2–4 concrete options, and + which one you'd pick and why. +3. Ask if there's anything else the developer wants to add, correct, or + detail further before work starts. +4. Once the developer responds, check whether new ambiguity was + introduced or anything is still unresolved. If so, repeat step 2–3 for + just the remaining points — don't re-ask what's already settled. +5. Once aligned, summarise the agreed plan in a few lines and confirm + before starting the actual work. + +## What this is not + +This isn't a stalling tactic or a way to avoid making calls — where the +codebase, the brief, or prior conversation already answers something, +don't re-ask it. This is specifically for the parts a developer would +otherwise have to catch in review because they weren't decided up front. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000..96c32ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# Street Listings — Project Context + +A property-listings app: Laravel 13 + Inertia + Vue 3 + Tailwind. Lists +properties for sale, organised by branch, with filtering and a detail view. + +## Stack + +- **Laravel 13** — no separate JSON API. Controllers return + `Inertia::render(...)` with props; API Resources define the shape of + those props. +- **Inertia + Vue 3** — pages in `resources/js/pages/`, shared components + in `resources/js/components/`. +- **Tailwind** for styling. +- **SQLite** locally (bundled with PHP, no separate DB server). + +## Layout + +app/ +Enums/ PropertyType, ListingStatus +Http/ +Controllers/ ListingController +Requests/ ListingIndexRequest (filter validation) +Resources/ ListingResource, BranchResource +Middleware/ ActAsDemoUser (auth stub — see below) +HandleInertiaRequests (shared props) +Models/ Branch, Listing +database/ +factories/ BranchFactory, ListingFactory (with states) +migrations/ branches, listings +seeders/ DatabaseSeeder +resources/js/ +pages/Listings/ Index.vue, Show.vue +components/ AppLayout, ListingCard, ListingFilters, Pagination +app.js Inertia entry point +routes/ web.php +tests/Feature/ ListingPageTest, ListingTest + + +## The domain + +- **Branch** — a name and a region. +- **Listing** — address, price, bedrooms, bathrooms, `property_type`, + `status` (`draft` / `live` / `under_offer` / `sold`), a branch, and a + `listed_at` date. + +## Conventions + +- **Routing**: `routes/web.php`, resolves to `Inertia::render`. +- **Resources**: one per model concept (`ListingResource`, + `BranchResource`), define the shape of data sent to the frontend. Don't + introduce a parallel JSON/REST style without good reason. +- **Enums**: domain vocabularies (`PropertyType`, `ListingStatus`) live in + `app/Enums/`. Prefer PHP enums over magic strings/constants. +- **Validation**: Form Requests (`app/Http/Requests/`), not inline + controller validation — see `ListingIndexRequest`. +- **Filters/state**: query-string driven, so a search is shareable, + bookmarkable, and survives the back button (`ListingFilters.vue` seeds + from the `filters` prop, re-issues `router.get` on submit). + +## Authentication + +Auth is stubbed via `App\Http\Middleware\ActAsDemoUser` — every request +resolves as the seeded demo user (`demo@street.example`). Build +user-scoped work against `$request->user()` / `auth()->user()` as normal; +it returns the demo user. The resolver returns `null` until the database +is seeded (`php artisan migrate --seed`). + +## Checks before considering work done + +php artisan test # PHPUnit feature/unit tests +vendor/bin/pint --test # Code style (Laravel Pint) +vendor/bin/phpstan analyse # Static analysis (Larastan, level 5) + + +All three run in CI (`.github/workflows/ci.yml`) on push and PR. diff --git a/app/Console/Commands/SendSavedSearchAlerts.php b/app/Console/Commands/SendSavedSearchAlerts.php new file mode 100755 index 0000000..44bcccc --- /dev/null +++ b/app/Console/Commands/SendSavedSearchAlerts.php @@ -0,0 +1,77 @@ +chunk(100, function ($savedSearches) use ($newAlerts) { + foreach ($savedSearches as $savedSearch) { + $filters = [ + 'property_type' => $savedSearch->property_type?->value, + 'max_price' => $savedSearch->max_price, + 'min_bedrooms' => $savedSearch->min_bedrooms, + 'region' => $savedSearch->region, + ]; + + $matches = Listing::query()->live()->filter($filters)->get(); + + foreach ($matches as $listing) { + $alert = Alert::query()->firstOrCreate([ + 'saved_search_id' => $savedSearch->id, + 'listing_id' => $listing->id, + ], [ + 'user_id' => $savedSearch->user_id, + ]); + + // Only rows created just now are "new" — a match that + // already had an Alert from a previous run is silently + // skipped, which is what prevents re-alerting/spam. + if ($alert->wasRecentlyCreated) { + $newAlerts->push($alert); + } + } + } + }); + + $newAlerts->groupBy('user_id')->each( + fn($alerts, $userId) => $this->notify((int)$userId, $alerts->pluck('listing_id')->all()) + ); + + $this->info("{$newAlerts->count()} new alert(s) created for {$newAlerts->pluck('user_id')->unique()->count()} user(s)."); + + return self::SUCCESS; + } + + /** + * Sends one user's digest of newly matched listings. + * + * In production this would dispatch a queued job (e.g. onto an SQS + * queue) that renders and sends a digest email/Notification, rather + * than notifying synchronously inside the command — so mail-provider + * latency or a large batch of alerts can't hold up the whole run. + * Logged here instead, since the exercise doesn't require real email + * or queue infrastructure. + */ + private function notify(int $userId, array $listingIds): void + { + Log::info('Saved search alert digest', [ + 'user_id' => $userId, + 'listing_ids' => $listingIds, + ]); + } +} diff --git a/app/Http/Controllers/ListingController.php b/app/Http/Controllers/ListingController.php index 5f8ee48..bb7f0f9 100644 --- a/app/Http/Controllers/ListingController.php +++ b/app/Http/Controllers/ListingController.php @@ -19,7 +19,11 @@ class ListingController extends Controller */ public function index(ListingIndexRequest $request): Response { - $query = Listing::query()->live(); + // Reuse the same filtering logic as the main listings search (see the + // `filter()` scope on Listing), so saved searches match the same criteria. + $query = Listing::query()->live()->filter( + $request->only('property_type', 'max_price', 'min_bedrooms', 'region') + ); if ($request->filled('property_type')) { $query->where('property_type', $request->string('property_type')); diff --git a/app/Http/Controllers/SavedSearchController.php b/app/Http/Controllers/SavedSearchController.php new file mode 100755 index 0000000..e4c363a --- /dev/null +++ b/app/Http/Controllers/SavedSearchController.php @@ -0,0 +1,59 @@ +only('property_type', 'max_price', 'min_bedrooms', 'region'); + + $listings = Listing::query() + ->live() + ->filter($filters) + ->latest('listed_at') + ->orderByDesc('id') + ->paginate($request->integer('per_page', 15)) + ->withQueryString(); + + return Inertia::render('SavedSearches/Index', [ + 'listings' => ListingResource::collection($listings), + 'branches' => BranchResource::collection(Branch::query()->orderBy('name')->get()), + 'propertyTypes' => PropertyType::options(), + 'filters' => $filters, + 'savedSearches' => SavedSearchResource::collection( + $request->user()->savedSearches()->latest()->get() + ), + ]); + } + + public function store(SavedSearchStoreRequest $request): RedirectResponse + { + $request->user()->savedSearches()->create($request->validated()); + + return back(); + } + + public function destroy(SavedSearch $savedSearch): RedirectResponse + { + abort_unless($savedSearch->user_id === request()->user()->id, 403); + + $savedSearch->delete(); + + return back(); + } +} diff --git a/app/Http/Requests/SavedSearchStoreRequest.php b/app/Http/Requests/SavedSearchStoreRequest.php new file mode 100755 index 0000000..eb92e53 --- /dev/null +++ b/app/Http/Requests/SavedSearchStoreRequest.php @@ -0,0 +1,29 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Resources/SavedSearchResource.php b/app/Http/Resources/SavedSearchResource.php new file mode 100755 index 0000000..5284fa4 --- /dev/null +++ b/app/Http/Resources/SavedSearchResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'property_type' => $this->property_type, + 'max_price' => $this->max_price, + 'min_bedrooms' => $this->min_bedrooms, + 'region' => $this->region, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Models/Alert.php b/app/Models/Alert.php new file mode 100755 index 0000000..406ffb4 --- /dev/null +++ b/app/Models/Alert.php @@ -0,0 +1,30 @@ +belongsTo(User::class); + } + + public function savedSearch(): BelongsTo + { + return $this->belongsTo(SavedSearch::class); + } + + public function listing(): BelongsTo + { + return $this->belongsTo(Listing::class); + } +} diff --git a/app/Models/Listing.php b/app/Models/Listing.php index 45efad3..01144fa 100644 --- a/app/Models/Listing.php +++ b/app/Models/Listing.php @@ -82,4 +82,29 @@ public function scopeLive(Builder $query): void { $query->where('status', ListingStatus::Live); } + + /** + * @param array $filters + */ + public function scopeFilter(Builder $query, array $filters): Builder + { + if (! empty($filters['property_type'] ?? null)) { + $query->where('property_type', $filters['property_type']); + } + + if (! empty($filters['max_price'] ?? null)) { + $query->where('price', '<=', $filters['max_price']); + } + + if (! empty($filters['min_bedrooms'] ?? null)) { + $query->where('bedrooms', '>=', $filters['min_bedrooms']); + } + + if (! empty($filters['region'] ?? null)) { + $region = $filters['region']; + $query->whereHas('branch', fn ($branchQuery) => $branchQuery->where('region', $region)); + } + + return $query; + } } diff --git a/app/Models/SavedSearch.php b/app/Models/SavedSearch.php new file mode 100755 index 0000000..630d21c --- /dev/null +++ b/app/Models/SavedSearch.php @@ -0,0 +1,39 @@ + PropertyType::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function alerts(): HasMany + { + return $this->hasMany(Alert::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..b43bcda 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,11 +3,13 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\SavedSearch; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; #[Fillable(['name', 'email', 'password'])] @@ -17,6 +19,11 @@ class User extends Authenticatable /** @use HasFactory */ use HasFactory, Notifiable; + public function savedSearches(): HasMany + { + return $this->hasMany(SavedSearch::class); + } + /** * Get the attributes that should be cast. * diff --git a/database/migrations/2026_08_31_184941_create_saved_searches_tables.php b/database/migrations/2026_08_31_184941_create_saved_searches_tables.php new file mode 100644 index 0000000..aaed34b --- /dev/null +++ b/database/migrations/2026_08_31_184941_create_saved_searches_tables.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('property_type')->nullable(); + $table->unsignedInteger('max_price')->nullable(); + $table->unsignedInteger('min_bedrooms')->nullable(); + $table->string('region')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_searches'); + } +}; diff --git a/database/migrations/2026_08_31_200646_create_alerts_table.php b/database/migrations/2026_08_31_200646_create_alerts_table.php new file mode 100644 index 0000000..91866a5 --- /dev/null +++ b/database/migrations/2026_08_31_200646_create_alerts_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saved_search_id')->constrained()->cascadeOnDelete(); + $table->foreignId('listing_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + // This is what makes a listing "new": if a row already exists + // for this pair, it's already been alerted on and gets skipped + // on future runs — no separate "last checked" state needed. + $table->unique(['saved_search_id', 'listing_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alerts'); + } +}; diff --git a/documentation/SendSavedSearchAlerts.md b/documentation/SendSavedSearchAlerts.md new file mode 100755 index 0000000..4be6579 --- /dev/null +++ b/documentation/SendSavedSearchAlerts.md @@ -0,0 +1,23 @@ +## Event vs a command +command + cron avoids the "fire an alert every time a listing event happens" spam problem entirely, and the dedup logic falls out naturally rather than needing separate anti-spam bookkeeping + + + +## Running Saved search alerts + +Run `php artisan alerts:send` to check all saved searches for newly-live +matching listings and notify users (currently logs a digest per user — +see the command's docblock for the production notification design). + +This is intended to run on a schedule, not on-demand — in production it +would be wired up via a cron job (or Laravel's scheduler) to run once a +day around midday, e.g.: + + 0 12 * * * cd /path-to-app && php artisan alerts:send + +Scheduling isn't configured in this exercise; run the command manually +to see it work. + +## In production +The command would actually generate an email and then would send to AWS SQS to be processed. +Depending on how many alerts would be sent on average you could fan the alerts batched using SQS Batch Actions. diff --git a/documentation/TestWriteUp.md b/documentation/TestWriteUp.md new file mode 100755 index 0000000..9734c79 --- /dev/null +++ b/documentation/TestWriteUp.md @@ -0,0 +1,71 @@ +## Challenges + +Before I could begin the assessment, I spent a significant amount of the allocated time setting up my development environment on an older Windows machine. This reduced the time available to complete the full assessment. + +I also only had access to the Claude free trial, which meant I was unable to connect Claude to the repository via MCP and use it to inspect the codebase directly. + +Rather than dwell on these limitations, I focused on completing as much of the core functionality as possible and documenting the decisions I made, along with the next steps I would take given more time. + +## Completed Tasks and Design Decisions + +I initially set up Claude and several of the skills I would normally use when working on a real-world project. Although I was unable to fully utilise these within the development environment because of the limitations above, I have included them to demonstrate how I currently use AI to improve my development workflow. + +One skill I find particularly useful is grill-me, especially when working on tasks where I want to reach a solution quickly. It effectively provides another perspective on the problem and allows me to challenge my own assumptions. + +Before starting development, I provided the assessment brief to Claude while also working through the brief myself. My intention was to make sure that both the requirements and my understanding of them were aligned before implementation began. Combined with the grill-me skill, this is similar to discussing a problem with a team of developers before committing to an approach. + +From an implementation perspective, I initially focused on being able to view, use and delete saved filters before moving on to the alert functionality. + +I was not able to complete the UI for viewing alerts within the available time. However, the underlying approach would follow the same MVC pattern already demonstrated by the saved alerts functionality, so I am confident this would be a relatively straightforward extension. + +For sending saved-search alerts, I chose to use a cron-based approach rather than an event-driven approach. I have documented the reasoning for this in documentation/SendSavedSearchAlerts.md. + +The main consideration was reducing the potential for alert spam. An alternative would be to use events to populate an alert_pending table and then process and group those alerts. While this would be a valid and potentially more scalable approach, I felt it introduced unnecessary complexity for the requirements of this assessment. + +### Create PR and other skills +I have also added a create-pr skill to demonstrate how I would approach creating pull requests in a real-world scenario. The aim is to keep PRs clean, consistent and easy for other developers to review. + +Other skills I would consider adding to my workflow would focus on maintaining code quality and consistency. For example, but not limited to: + +- Ensuring coding standards are followed consistently. +- Keeping comments concise, useful and easy to understand. +- Reviewing code for unnecessary complexity and avoiding introducing complex solutions simply for the sake of it. (Especially when using Opus on Ultra Code) +- Ensuring changes remain focused and relevant to the task being completed. +These skills would help maintain a consistent development workflow while allowing AI to assist with the parts of the development process where it can provide the most value. + +## Next Steps +### Unit Testing + +The main missing piece is unit test coverage. + +Given more time, I would add tests around the core functionality, particularly the calls to the queue and alert-sending functionality. These external dependencies would be mocked so that the tests could focus on the behaviour of the application rather than the external services themselves. + +### Viewing Alerts + +As mentioned above, the ability to view alerts is not completed. + +I would implement this using the same MVC approach already used elsewhere in the solution. I would not expect to need a new model, but I would extend the existing alerts model to record when an alert was sent. + +## Optional Extras and Further Improvements +### Backfilling + +I am a believer in giving customers as much control as possible. + +Although an older match would not normally constitute a new alert, I think there is an opportunity to give customers the option to request previous results. For example, a saved alert could have an additional "Send these results" action, allowing the customer to receive the current matching results on demand. + +### Duplicate Alerts +The solution keeps track of the listing_id on the alert model. This makes it straightforward to check whether a particular listing has already been sent to the customer and exclude it from future alerts. + +This also helps address one of the requirements highlighted in the brief: reducing unnecessary or duplicate notifications. + +### Search Criteria +I would use a price range rather than only a maximum price. I believe this is more familiar to customers and provides greater control over their searches. + +I would also consider adding the number of bathrooms as a search criterion, along with a checklist of common property features such as: + +Garden +Garage +Parking +Balcony +Conservatory +These would give customers more control over the properties they receive in their alerts while remaining intuitive to use. diff --git a/resources/js/components/AppLayout.vue b/resources/js/components/AppLayout.vue index a0625e7..7199427 100644 --- a/resources/js/components/AppLayout.vue +++ b/resources/js/components/AppLayout.vue @@ -10,12 +10,30 @@ defineProps({