Skip to content
Open
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
65 changes: 65 additions & 0 deletions .claude/skills/create-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<concise title>" --body "<description>" --base main
gh pr ready <pr-number> # 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 <pr-number> --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.
49 changes: 49 additions & 0 deletions .claude/skills/grill-me/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions app/Console/Commands/SendSavedSearchAlerts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php


namespace App\Console\Commands;

use App\Models\Alert;
use App\Models\Listing;
use App\Models\SavedSearch;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class SendSavedSearchAlerts extends Command
{
protected $signature = 'alerts:send';

protected $description = 'Check saved searches for newly-live matching listings and notify users.';

public function handle(): int
{
$newAlerts = collect();

SavedSearch::query()->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,
]);
}
}
6 changes: 5 additions & 1 deletion app/Http/Controllers/ListingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
59 changes: 59 additions & 0 deletions app/Http/Controllers/SavedSearchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Http\Controllers;

use App\Enums\PropertyType;
use App\Http\Requests\SavedSearchStoreRequest;
use App\Http\Resources\BranchResource;
use App\Http\Resources\ListingResource;
use App\Http\Resources\SavedSearchResource;
use App\Models\Branch;
use App\Models\Listing;
use App\Models\SavedSearch;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class SavedSearchController extends Controller
{
public function index(): Response
{
$request = request();

$filters = $request->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();
}
}
29 changes: 29 additions & 0 deletions app/Http/Requests/SavedSearchStoreRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php


namespace App\Http\Requests;

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

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

/**
* @return array<string, mixed>
*/
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'],
];
}
}
24 changes: 24 additions & 0 deletions app/Http/Resources/SavedSearchResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SavedSearchResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}
Loading