# Layer 12 Plan 1: Reviews Foundation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Each task ends in a green test run; do not move to the next task with red tests.

**Goal:** Stand up the buyer-side review write path + public read aggregates for Alqove's marketplace trust loop. Plan 1 ships the `reviews` table + `Review` model + factory, a dedicated `review_attachment_uploads` table + model + factory (parallel to Layer 9's `message_attachment_uploads`), six denormalized rating counters on `stores`, a drop of the legacy unused `stores.rating` column, a single `ReviewWriter` service that owns the transactional create-or-edit + aggregate recompute, a `ReviewEligibility` service that decides whether a buyer can review or edit, six HTTP endpoints (two write, three read, plus a dedicated review-attachment upload endpoint), two queueable notifications, and the corresponding OpenAPI + types + api-client + frontend wiring. Acceptance: a buyer can submit a 5-star overall + 4 dimension rating + title + body + 0–4 photo attachments on any delivered `order_item` they own; the store's `average_rating`, `review_count`, and four dimension averages move atomically in the same transaction; the public store page renders the new review immediately; the seller receives a "New review on your shop" notification; the same buyer can edit the review for 30 days from `created_at`; after 30 days the edit returns 422. No reports, no moderation, no hide/restore — Plan 2 owns those. No Typesense push — Plan 3 owns that.

**Architecture:** (1) **Schema** — one migration creates the `reviews` table (UUID PK; five tinyint rating columns; title varchar; body text; `state` string enum stub with one case `visible`; `edited_at` nullable; `created_at` immutable; FKs to `order_items`, `orders`, `stores`, `users`; unique on `order_item_id`; index on `(store_id, state, created_at)` for the public-read path; index on `(reviewer_user_id, created_at)` for `/me/reviews`). A second migration adds six nullable/zero-defaulted counter columns to `stores`. A third migration creates `review_attachment_uploads` parallel to Layer 9's `message_attachment_uploads` (UUID PK; `order_item_id` FK; `user_id` FK; timestamps; index on `(user_id, created_at)`). A fourth migration drops the legacy unused `stores.rating` column (audit found it API-exposed in three Resources but always 0 — see the cleanup task at end of Phase A). Postgres-only CHECK constraints (gated behind `DB::connection()->getDriverName() === 'pgsql'`) enforce `BETWEEN 1 AND 5` on the five rating columns. Note: `state` is a string column ready to widen — Plan 2 adds the `hidden` value + hide-related columns in its own migration. (2) **Eligibility service** — `ReviewEligibility::canCreate(OrderItem, User): bool` returns false for non-buyer / non-delivered / already-reviewed; the controller maps the failure reason to 403 / 422 / 409 via three thin guard methods (`assertBuyer`, `assertDelivered`, `assertNotAlreadyReviewed`) that `abort()` inline. `ReviewEligibility::canEdit(Review, User): bool` returns false for non-owner / out-of-window; controller assertions map to 403 / 422. (3) **Writer service** — `ReviewWriter::create(OrderItem, User, NewReviewInput): Review` and `ReviewWriter::update(Review, EditReviewInput): Review` both wrap their work in `DB::transaction(function() { Store::lockForUpdate(); persist review; recompute aggregates; })`. Photo attachments come in as `attachment_ids: string[]` referencing pre-uploaded `ReviewAttachmentUpload` rows from the dedicated upload endpoint (`POST /v1/order-items/{order_item}/review-attachments`); the writer re-parents the Spatie Media Library rows onto the new/edited Review via `$media->move($review, 'review_attachments')`. The default-dimension-to-overall posture is implemented inside the writer at persist time: any null in the four dimension input fields is coerced to `$input->rating`. The aggregate recompute is from scratch using Eloquent collection methods on `$store->reviews()->where('state', 'visible')->get()` — no raw SQL aggregates, no event listeners. (4) **Endpoints** — `POST /v1/order-items/{order_item}/reviews` (auth, eligibility gates, calls `ReviewWriter::create`, fires both notifications, returns 201 with a `ReviewResource`); `PATCH /v1/reviews/{review}` (auth, ownership + window gates, calls `ReviewWriter::update`, returns 200); `POST /v1/order-items/{order_item}/review-attachments` (auth, ownership + delivered-order check, returns 201 with `{ id, url, thumb_url }`); `GET /v1/me/reviews` (auth, paginated); `GET /v1/stores/{store}/reviews` (**no auth**, paginated, `state = visible` only); `GET /v1/stores/{store}/rating-summary` (**no auth**, lightweight `{average_rating, review_count, distribution: {1: count, 2: count, ...}, dimensions: {item_as_described, shipping_speed, communication, packaging}}`). (5) **Notifications** — `ReviewPublishedNotification` (recipient: `$review->reviewer`, category `Reviews`, ShouldQueue, mail + database channels) and `ReviewReceivedNotification` (recipient: `$review->store->owner`, same category, same channels). A new `NotificationCategory::Reviews` enum case lands alongside the existing ten cases. (6) **OpenAPI** — six paths + a `Review` schema + a `RatingSummary` schema + a `NewReviewInput` request body schema + an `EditReviewInput` + a `ReviewAttachmentUpload` response schema. (7) **Frontend** — `useReviewsForStore`, `useRatingSummary`, `useMyReviews`, `useCreateReview`, `useUpdateReview` TanStack hooks; a `<ReviewModal>` component with the 5-star overall picker (the four dimension pickers initialize to the overall value on overall-change), the title/body inputs, and a `<ReviewPhotoUploader>` that drives the new dedicated `POST /v1/order-items/{order_item}/review-attachments` endpoint (parallel to Layer 9's messaging uploader pattern, but order-item-scoped); a "Leave a review" CTA on the buyer purchase-detail page for every delivered `order_item` without a review; a "View your review" affordance with edit button for already-reviewed items within the 30-day window; a `/me/reviews` page; a store-detail page extension with a `<RatingBadge>` + `<DimensionBreakdownCard>` + paginated reviews list; an item-detail badge "Sold by [Store] ★ 4.8 (142 reviews)" near seller info; a `<SellerReviewsWidget>` aggregate widget on `/seller` home; a `/seller/reviews` page (read-only chronological list + the aggregate widget — no filter pills since all reviews are `visible` in Plan 1; Plan 2 adds the filter once `hidden` exists).

**Tech Stack:** Laravel 12, PHPUnit class-based feature tests under `api/tests/Feature/Reviews/` (mirrors the `tests/Feature/Ledger/` and `tests/Feature/Returns/` layout from Layers 10/11), Postgres 17 in CI + SQLite as a fallback for some local runs (the CHECK constraints are gated by driver), `ramsey/uuid`-backed `HasUuid` trait (existing on `Order`, `OrderItem`, `OrderReturn`, `SellerLedger`), Spatie Media Library v11 for the photo attachments (already wired on `MessageAttachmentUpload`), `Notification::send` with `ShouldQueue` notifications dispatched after the transaction commits, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-13-layer-12-reviews-design.md`. The "Data model", "Default-dimension-to-overall posture", "Lifecycle walkthrough", and "Plan 1 — Foundation" sections are load-bearing for this plan; re-read them before starting Phase A.

**Prerequisites:**
- API head: `abdb35d` (Layer 11 fully shipped + post-QA fixes + factory audit fixes). **1021 tests passing.** No reviews code exists anywhere in the codebase — no `reviews` table, no `Review` model, no review-related routes or notifications.
- Web head: `5dd3129` (Layer 11 UI followup fixes). **308 tests passing, 1 skipped.** No reviews UI surfaces exist.
- The integration points Plan 1 reuses already exist:
  - `App\Modules\Messaging\Services\MessagePoster::attachMedia` at `api/app/Modules/Messaging/Services/MessagePoster.php` lines 85–103 is the canonical staged-upload-to-final-attachment pattern: client uploads a file to `POST /v1/orders/{order}/messages/attachments` (handled by `App\Modules\Messaging\Controllers\AttachmentController::store`) which creates a `MessageAttachmentUpload` row and attaches the file to its Spatie `staged` collection; the client gets back the upload id; on final submit the consumer service reads `MessageAttachmentUpload::whereIn('id', $ids)->where('user_id', $author->id)->get()`, validates ownership, then `$media->move($targetModel, 'message_attachments')` re-parents the Spatie media row onto the final model and `$upload->delete()`s the now-empty parent. **Plan 1 ships a parallel dedicated upload endpoint for reviews** (`POST /v1/order-items/{order_item}/review-attachments`, controller `ReviewAttachmentUploadController::store`, model `ReviewAttachmentUpload`, table `review_attachment_uploads`) that mirrors the messaging flow line-for-line. The only differences: (a) order-item-scoped instead of order-scoped (review attachments belong to a specific item, not just an order); (b) the dedicated `ReviewAttachmentUpload` model + table replaces the cross-domain reuse of `MessageAttachmentUpload`; (c) the final collection on the Review model is `review_attachments`. The validation rules (`mimes:jpeg,jpg,png,heic`, `max:5120`), Spatie `staged` collection name, response shape (`{ data: { id, url, content_type, size_bytes } }` — Plan 1 adds a `thumb_url` field for parity with the spec), and the writer's `attachMedia` helper that re-parents staged media are otherwise identical to Layer 9.
  - `App\Support\Enums\NotificationCategory` at `api/app/Support/Enums/NotificationCategory.php` defines ten cases (`Orders`, `Shipping`, `Payouts`, `Promotions`, `PriceDrops`, `Account`, `Disputes`, `AccountAdmin`, `Support`, `Returns`) plus an `isTransactional()` method. Plan 1 adds `case Reviews = 'reviews';` and leaves `isTransactional()` returning false for it (reviews are not transaction-critical — buyers can opt out without breaking the marketplace).
  - `App\Modules\Notifications\Services\NotificationPreferenceGate::channelsFor(User, NotificationCategory, list<string>): list<string>` at `api/app/Modules/Notifications/Services/NotificationPreferenceGate.php` is the gate every existing notification's `via()` method calls. Reuse unchanged.
  - `App\Models\OrderItem` at `api/app/Models/OrderItem.php` has `order(): BelongsTo`; `Order` has `purchase(): BelongsTo` and `delivered_at` (cast as datetime); `Purchase` has `buyer_id` + `buyer(): BelongsTo`. The chain `$orderItem->order->purchase->buyer_id` is how the eligibility service identifies the buyer-of-record.
  - `App\Models\Store` at `api/app/Models/Store.php` already has `owner(): BelongsTo` (line 85) and a `HasMany` pattern for related collections (Plan 1 adds `reviews(): HasMany`).
  - **Public route convention** — confirmed by reading `api/app/Modules/Stores/routes.php` lines 11–13 and `api/app/Modules/Items/routes.php` lines 9–11: public read routes live **outside** the `Route::middleware('auth:sanctum')->group(...)` block, registered directly at module top level. The two new public review endpoints (`GET /v1/stores/{store}/reviews` and `GET /v1/stores/{store}/rating-summary`) follow this convention — they sit outside any auth group.
  - **Inline-abort error pattern** — confirmed by reading `api/app/Modules/Returns/Services/ProactiveReturnService.php` lines 146–165: services in this codebase use `abort(403, '…')` / `abort(422, '…')` / `abort(409, '…')` inline rather than throwing typed exceptions. Plan 1 follows the same pattern for `ReviewEligibility` failure paths.
  - **`nullableUuidMorphs` not used in this plan** — `Review` doesn't have a polymorphic source; it points at fixed `order_item_id`, `order_id`, `store_id`, `reviewer_user_id` columns. Photo attachments live as Spatie Media Library rows polymorphically attached to the `Review` model; the existing media table already has `uuidMorphs('model')` so Plan 1 needs no migration there.
- No Layer 12 prerequisites — this is Plan 1.

**Successor plan:** `2026-XX-XX-layer-12-reports-moderation.md` (Plan 2) — `review_reports` table + model + factory; `POST /v1/reviews/{review}/reports`; admin queue (`GET /v1/admin/review-reports`, `POST /v1/admin/review-reports/{report}/resolve`, `POST /v1/admin/reviews/{review}/restore`); the `hidden` state value + `hidden_by_admin_id` / `hidden_at` / `hide_reason` columns added by a Plan-2 migration; `ReviewReportedNotification` (admin), `ReviewHiddenNotification` (buyer); activity-log entries (`review.report_resolved`, `review.admin_hidden`, `review.admin_restored`); admin queue page + report modal + per-review hide/restore on the admin store-detail page; filter pills on `/seller/reviews` for hidden vs visible. Plan 3 (`2026-XX-XX-layer-12-reviews-discovery.md`) adds the Typesense field push + the listing-card inline badge + the activity-log `review.buyer_edited` audit row + minor polish.

---

## Phase A — Schema

### Task 1: `reviews` table + `Review` model + factory + Store relation

**Files:**
- Create: `api/database/migrations/2026_05_13_100001_create_reviews_table.php`
- Create: `api/app/Models/Review.php`
- Create: `api/database/factories/ReviewFactory.php`
- Update: `api/app/Models/Store.php` (add `reviews(): HasMany` relation)
- Test: `api/tests/Feature/Reviews/ReviewSchemaTest.php`

Migration:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('reviews', function (Blueprint $t) {
            $t->uuid('id')->primary();
            $t->foreignUuid('order_item_id')->constrained('order_items');
            $t->foreignUuid('order_id')->constrained('orders');
            $t->foreignUuid('store_id')->constrained('stores');
            $t->foreignUuid('reviewer_user_id')->constrained('users');

            $t->unsignedTinyInteger('rating');                       // overall, 1–5, required
            $t->unsignedTinyInteger('rating_item_as_described');     // dimension, 1–5, defaulted-to-overall at write time
            $t->unsignedTinyInteger('rating_shipping_speed');
            $t->unsignedTinyInteger('rating_communication');
            $t->unsignedTinyInteger('rating_packaging');

            $t->string('title', 120)->nullable();
            $t->text('body');                                        // FormRequest enforces min 20 / max 2000

            $t->string('state', 16)->default('visible');             // 'visible' only in Plan 1; 'hidden' added in Plan 2
            $t->timestamp('edited_at')->nullable();
            $t->timestamps();                                        // created_at immutable across edits; updated_at moves on every save

            $t->unique('order_item_id');                             // one review per transaction line
            $t->index(['store_id', 'state', 'created_at'], 'reviews_store_state_created_idx');
            $t->index(['reviewer_user_id', 'created_at'], 'reviews_reviewer_created_idx');
        });

        // Defence-in-depth: Postgres CHECK constraints on the five rating columns.
        // Gated by driver — SQLite (used in some local test runs) doesn't enforce these.
        if (DB::connection()->getDriverName() === 'pgsql') {
            foreach (['rating', 'rating_item_as_described', 'rating_shipping_speed', 'rating_communication', 'rating_packaging'] as $col) {
                DB::statement("ALTER TABLE reviews ADD CONSTRAINT reviews_{$col}_range CHECK ({$col} BETWEEN 1 AND 5)");
            }
            DB::statement("ALTER TABLE reviews ADD CONSTRAINT reviews_state_valid CHECK (state IN ('visible'))");
            // Note: Plan 2's migration relaxes this to ('visible', 'hidden').
        }
    }

    public function down(): void
    {
        Schema::dropIfExists('reviews');
    }
};
```

> **Plan note (CHECK constraints gated to pgsql) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Matches the Layer 11 `payouts` migration pattern at `api/database/migrations/2026_05_11_200001_create_payouts_table.php` line 38. Application-layer FormRequest validation is the primary gate (`integer|min:1|max:5`); CHECK is the belt-and-suspenders. If a future contributor writes a row via `DB::statement(...)` bypassing the model + service, the CHECK still rejects bad values in production. Tests run against Postgres in CI so the checks are exercised. **If you prefer FormRequest-only**, drop the CHECK block and rely on validation.

> **Plan note (`state` as varchar(16) with a single-value CHECK in Plan 1):** `state` is a string column, not a Postgres enum type. Reason: Postgres enum types require an explicit `CREATE TYPE` + `ALTER TYPE ADD VALUE` to extend, and `ADD VALUE` cannot run inside a transaction (so Laravel migrations can't safely add an enum case). String + CHECK is the conventional Laravel idiom for this; matches `seller_ledger.direction` (string + CHECK in Plan 11). Plan 2's migration relaxes the CHECK to `IN ('visible', 'hidden')` and adds `hidden_by_admin_id` / `hidden_at` / `hide_reason` columns then.

> **Plan note (no soft-delete on `reviews`):** Spec line 234 explicitly says buyer cannot delete a review; only admin hide-via-Plan-2 removes them from view. No `deleted_at` column. If a future need to "withdraw" a review surfaces, it's a Plan-2+ concern.

Model:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

class Review extends Model implements HasMedia
{
    use HasFactory;
    use HasUuid;
    use InteractsWithMedia;

    protected $fillable = [
        'order_item_id', 'order_id', 'store_id', 'reviewer_user_id',
        'rating', 'rating_item_as_described', 'rating_shipping_speed',
        'rating_communication', 'rating_packaging',
        'title', 'body', 'state', 'edited_at',
    ];

    protected function casts(): array
    {
        return [
            'rating' => 'integer',
            'rating_item_as_described' => 'integer',
            'rating_shipping_speed' => 'integer',
            'rating_communication' => 'integer',
            'rating_packaging' => 'integer',
            'edited_at' => 'datetime',
        ];
    }

    public function orderItem(): BelongsTo
    {
        return $this->belongsTo(OrderItem::class);
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    public function reviewer(): BelongsTo
    {
        return $this->belongsTo(User::class, 'reviewer_user_id');
    }

    /** Edit window: 30 days from created_at. After that, PATCH returns 422. */
    public function isWithinEditWindow(): bool
    {
        return $this->created_at !== null
            && $this->created_at->copy()->addDays(30)->isFuture();
    }
}
```

> **Plan note (`InteractsWithMedia` not `Attachable`):** The codebase uses Spatie Media Library's `HasMedia` interface + `InteractsWithMedia` trait — there is no separate `Attachable` interface. Confirmed by reading `api/app/Models/MessageAttachmentUpload.php` lines 10–17. The plan's earlier reference to "Attachable" in the design context was shorthand; the actual API is Spatie's.

> **Plan note (no business logic on the model):** Following the codebase convention, the model is a thin data carrier. `isWithinEditWindow()` is the one exception — it's a tiny derived predicate that both the service and the API resource use, so colocating it on the model is cleaner than two duplicate one-liners.

Factory:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\Review;
use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Review>
 */
class ReviewFactory extends Factory
{
    protected $model = Review::class;

    public function definition(): array
    {
        $rating = fake()->numberBetween(1, 5);

        // Coherence: build a buyer + purchase + order + order_item chain so
        // eligibility-driven tests have aligned ids by default. Tests that
        // need a specific buyer should use the ->madeBy() state below.
        $buyer = User::factory();
        $purchase = Purchase::factory()->state(fn () => ['buyer_id' => $buyer]);
        $order = Order::factory()->state(fn () => ['purchase_id' => $purchase, 'delivered_at' => now()->subDay()]);
        $orderItem = OrderItem::factory()->state(fn () => ['order_id' => $order]);

        return [
            'id' => fake()->uuid(),
            'order_item_id' => $orderItem,
            'order_id' => fn (array $attrs) => OrderItem::find($attrs['order_item_id'])?->order_id ?? $order,
            'store_id' => fn (array $attrs) => Order::find($attrs['order_id'])?->store_id ?? Store::factory(),
            'reviewer_user_id' => $buyer,
            'rating' => $rating,
            'rating_item_as_described' => $rating,
            'rating_shipping_speed' => $rating,
            'rating_communication' => $rating,
            'rating_packaging' => $rating,
            'title' => fake()->optional()->sentence(4),
            'body' => fake()->paragraph(3),
            'state' => 'visible',
            'edited_at' => null,
        ];
    }

    /** Use when a test wants a specific buyer; ensures the order_item chain's purchase.buyer_id matches. */
    public function madeBy(User $buyer): static
    {
        return $this->state(function () use ($buyer) {
            $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
            $order = Order::factory()->create([
                'purchase_id' => $purchase->id,
                'delivered_at' => now()->subDay(),
            ]);
            $orderItem = OrderItem::factory()->create(['order_id' => $order->id]);

            return [
                'order_item_id' => $orderItem->id,
                'order_id' => $order->id,
                'store_id' => $order->store_id,
                'reviewer_user_id' => $buyer->id,
            ];
        });
    }

    /** Use when a test wants to target a specific store. Re-aligns the order chain. */
    public function forStore(Store $store): static
    {
        return $this->state(function () use ($store) {
            $purchase = Purchase::factory()->create();
            $order = Order::factory()->create([
                'purchase_id' => $purchase->id,
                'store_id' => $store->id,
                'delivered_at' => now()->subDay(),
            ]);
            $orderItem = OrderItem::factory()->create(['order_id' => $order->id]);

            return [
                'order_item_id' => $orderItem->id,
                'order_id' => $order->id,
                'store_id' => $store->id,
                'reviewer_user_id' => $purchase->buyer_id,
            ];
        });
    }
}
```

> **Plan note (factory coherence) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The recent factory audit (referenced in the task brief) found that lazy / mismatched foreign keys in `SellerLedgerFactory` produced silently incoherent data — `store_id` not matching `order.store_id`, etc. — that broke eligibility tests in unsubtle ways. **Plan 1's `ReviewFactory` defaults all four FKs to a coherent chain at definition time**: it builds `User → Purchase → Order → OrderItem` and derives `order_id` + `store_id` + `reviewer_user_id` from that chain so the unique constraint on `order_item_id` is also respected (every default review references a brand-new order_item). Two state methods (`madeBy`, `forStore`) let tests override coherently. **Tests that want incoherent data — e.g., the "reviewer is not the buyer" eligibility test — must build it explicitly** rather than relying on a default that lets it slip in by accident.

> **Plan note (`delivered_at: now()->subDay()` in the factory default):** Every default Review references a delivered order. This matches the spec — a Review can only exist on a delivered order_item — and makes eligibility-pass test scenarios the default. Tests that need an undelivered scenario override `delivered_at` explicitly on the Order before creating the Review.

`Store` model update — add the relation alongside `payouts()` and `ledgerEntries()`:

```php
public function reviews(): HasMany
{
    return $this->hasMany(Review::class);
}
```

- [ ] **Step 1: Write the failing test** — `api/tests/Feature/Reviews/ReviewSchemaTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Reviews;

use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Review;
use App\Models\Store;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class ReviewSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_reviews_table_exists_with_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('reviews'));
        foreach ([
            'id', 'order_item_id', 'order_id', 'store_id', 'reviewer_user_id',
            'rating', 'rating_item_as_described', 'rating_shipping_speed',
            'rating_communication', 'rating_packaging',
            'title', 'body', 'state', 'edited_at', 'created_at', 'updated_at',
        ] as $col) {
            $this->assertTrue(Schema::hasColumn('reviews', $col), "reviews.$col missing");
        }
    }

    public function test_store_gains_six_counter_columns(): void
    {
        // Asserted again in Task 2's test; included here as a smoke check.
        foreach ([
            'average_rating', 'review_count',
            'avg_item_as_described', 'avg_shipping_speed',
            'avg_communication', 'avg_packaging',
        ] as $col) {
            $this->assertTrue(Schema::hasColumn('stores', $col), "stores.$col missing");
        }
    }

    public function test_order_item_id_is_unique(): void
    {
        $review = Review::factory()->create();
        $this->expectException(QueryException::class);
        Review::factory()->create(['order_item_id' => $review->order_item_id]);
    }

    public function test_rating_outside_1_5_is_rejected_on_pgsql(): void
    {
        if (\DB::connection()->getDriverName() !== 'pgsql') {
            $this->markTestSkipped('CHECK constraint only runs on pgsql.');
        }
        $this->expectException(QueryException::class);
        Review::factory()->create(['rating' => 0]);
    }

    public function test_state_invalid_value_is_rejected_on_pgsql(): void
    {
        if (\DB::connection()->getDriverName() !== 'pgsql') {
            $this->markTestSkipped('CHECK constraint only runs on pgsql.');
        }
        $this->expectException(QueryException::class);
        Review::factory()->create(['state' => 'hidden']);  // Plan 2 will widen this; today must throw.
    }

    public function test_factory_default_produces_coherent_chain(): void
    {
        $review = Review::factory()->create();
        $this->assertSame((string) $review->order->store_id, (string) $review->store_id);
        $this->assertSame((string) $review->order->purchase->buyer_id, (string) $review->reviewer_user_id);
        $this->assertSame((string) $review->orderItem->order_id, (string) $review->order_id);
        $this->assertNotNull($review->order->delivered_at);
    }

    public function test_made_by_state_aligns_buyer(): void
    {
        $buyer = User::factory()->create();
        $review = Review::factory()->madeBy($buyer)->create();
        $this->assertSame($buyer->id, $review->reviewer_user_id);
        $this->assertSame($buyer->id, $review->order->purchase->buyer_id);
    }

    public function test_for_store_state_aligns_store(): void
    {
        $store = Store::factory()->create();
        $review = Review::factory()->forStore($store)->create();
        $this->assertSame($store->id, $review->store_id);
        $this->assertSame($store->id, $review->order->store_id);
    }

    public function test_is_within_edit_window_predicate(): void
    {
        $fresh = Review::factory()->create(['created_at' => now()->subDays(5)]);
        $expired = Review::factory()->create(['created_at' => now()->subDays(31)]);
        $this->assertTrue($fresh->isWithinEditWindow());
        $this->assertFalse($expired->isWithinEditWindow());
    }

    public function test_store_reviews_relation(): void
    {
        $store = Store::factory()->create();
        Review::factory()->forStore($store)->count(3)->create();
        $this->assertSame(3, $store->fresh()->reviews()->count());
    }
}
```

- [ ] **Step 2: Run, confirm failure** — table missing; model missing; relation missing.

- [ ] **Step 3: Implement** the migration, the model, the factory, and the `Store::reviews()` relation. Run `php artisan migrate` inside the docker compose service. Confirm the new indexes show up via `\d reviews` in psql.

- [ ] **Step 4: Run; iterate to 10/10 PASS.** Tests skipping on non-pgsql is acceptable for local SQLite runs; CI runs on pgsql so the CHECK assertions exercise.

---

### Task 2: Six denormalized counter columns on `stores`

**Files:**
- Create: `api/database/migrations/2026_05_13_100002_add_review_counters_to_stores_table.php`
- Test: `api/tests/Feature/Reviews/StoreCounterColumnsTest.php`

Migration:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('stores', function (Blueprint $t) {
            // Overall — null until first visible review exists.
            $t->float('average_rating')->nullable()->after('total_sales');
            $t->unsignedInteger('review_count')->default(0)->after('average_rating');

            // Dimension breakdown — same nullable posture.
            $t->float('avg_item_as_described')->nullable()->after('review_count');
            $t->float('avg_shipping_speed')->nullable()->after('avg_item_as_described');
            $t->float('avg_communication')->nullable()->after('avg_shipping_speed');
            $t->float('avg_packaging')->nullable()->after('avg_communication');
        });
    }

    public function down(): void
    {
        Schema::table('stores', function (Blueprint $t) {
            $t->dropColumn([
                'average_rating', 'review_count',
                'avg_item_as_described', 'avg_shipping_speed',
                'avg_communication', 'avg_packaging',
            ]);
        });
    }
};
```

> **Plan note (`rating` column on `stores` — cleanup deferred to Task 2c):** Audit revealed `stores.rating` already exists (legacy column from a prior layer; defaulted to 0 in `StoreFactory`; API-exposed in `StorePublicResource:19`, `AdminStoreResource:23`, `AdminStoreDetail:93` despite being functionally inert). **Plan 1 cleans it up in Task 2c** (the last task in Phase A) — drops the column, removes the three Resource lines, removes the `$casts` + `StoreFactory` default, updates two web test fixtures. The new `average_rating` counter (this task) is the semantic replacement, reachable via `GET /v1/stores/{store}/rating-summary`. See Task 2c for the full migration + cleanup plan.

> **Plan note (no CHECK on counter columns):** The counters are computed server-side from valid review rows. The Review-row CHECK constraints already ensure every input is 1–5; a mean over those inputs is by construction in `[1.0, 5.0]`. Defence-in-depth is not worth the migration noise here.

- [ ] **Step 1: Write the failing test** — `StoreCounterColumnsTest.php`:

```php
public function test_stores_table_has_six_counter_columns(): void
public function test_average_rating_defaults_to_null_on_new_store(): void
public function test_review_count_defaults_to_zero_on_new_store(): void
public function test_dimension_counters_default_to_null(): void
// (note: a previous draft included `test_existing_store_rating_column_is_untouched`;
//  Amendment 2 removes that assertion — Task 2c drops the legacy column entirely.
//  The negative-assertion that `rating` no longer appears in Store API responses
//  lives in Task 2c.)
```

- [ ] **Step 2: Run, confirm failure** — columns missing.

- [ ] **Step 3: Implement** the migration. `php artisan migrate`.

- [ ] **Step 4: Run; iterate to 5/5 PASS.**

---

### Task 2b: `review_attachment_uploads` table + `ReviewAttachmentUpload` model + factory

**Files:**
- Create: `api/database/migrations/2026_05_13_100003_create_review_attachment_uploads_table.php`
- Create: `api/app/Models/ReviewAttachmentUpload.php`
- Create: `api/database/factories/ReviewAttachmentUploadFactory.php`
- Test: `api/tests/Feature/Reviews/ReviewAttachmentUploadSchemaTest.php`

Migration (parallel to Layer 9's `2026_05_06_100001_create_message_attachment_uploads_table.php`):

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('review_attachment_uploads', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('order_item_id')->constrained('order_items')->cascadeOnDelete();
            $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
            $table->timestamps();

            $table->index(['user_id', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('review_attachment_uploads');
    }
};
```

Model (mirrors `api/app/Models/MessageAttachmentUpload.php` line-for-line; only difference is `order_item_id` FK):

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

class ReviewAttachmentUpload extends Model implements HasMedia
{
    use HasFactory;
    use HasUuid;
    use InteractsWithMedia;

    protected $fillable = ['order_item_id', 'user_id'];

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('staged')
            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heic']);
    }

    public function orderItem(): BelongsTo
    {
        return $this->belongsTo(OrderItem::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}
```

Factory (mirrors `MessageAttachmentUploadFactory` — coherent default chain via the existing `OrderItem::factory()`):

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\OrderItem;
use App\Models\ReviewAttachmentUpload;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class ReviewAttachmentUploadFactory extends Factory
{
    protected $model = ReviewAttachmentUpload::class;

    public function definition(): array
    {
        return [
            'order_item_id' => OrderItem::factory(),
            'user_id' => User::factory(),
        ];
    }

    public function forOrderItem(OrderItem $orderItem): self
    {
        return $this->state(fn () => [
            'order_item_id' => $orderItem->id,
        ]);
    }
}
```

> **Plan note (dedicated table parallel to Layer 9):** This is the Amendment-1 implementation of Architectural Call #1 (see the Plan note above the `ReviewWriter::attachMedia` block). The shape is identical to `message_attachment_uploads` except for the `order_item_id` FK (vs `order_id`). The polymorphic media row lives in Spatie's `media` table (no new column there) — the existing `uuidMorphs('model')` on that table handles both `MessageAttachmentUpload` and `ReviewAttachmentUpload` polymorphically.

> **Plan note (factory coherence — `forOrderItem` state):** Matches the broader factory-audit posture from Task 1. Tests that want a row owned by a specific buyer + item use `ReviewAttachmentUpload::factory()->forOrderItem($orderItem)->create(['user_id' => $buyer->id])`. The default chain produces a brand-new `User → OrderItem` pair which is fine for any test that doesn't care about cross-row coherence.

- [ ] **Step 1: Write the failing test** — `ReviewAttachmentUploadSchemaTest.php`:

```php
public function test_table_exists_with_required_columns(): void
public function test_belongs_to_order_item(): void
public function test_belongs_to_user(): void
public function test_factory_produces_coherent_chain(): void
public function test_staged_media_collection_accepts_image_mimes(): void
// (attach a JPEG fixture; assert media row exists in `staged` collection)
public function test_for_order_item_state_overrides_default_chain(): void
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement** the migration, model, factory. `php artisan migrate`.

- [ ] **Step 4: Run; iterate to ~6/6 PASS.**

---

### Task 2c: Drop legacy `stores.rating` column (cleanup)

**Files:**
- Create: `api/database/migrations/2026_05_13_100099_drop_legacy_rating_from_stores.php`
- Update: `api/app/Models/Store.php` (remove `'rating'` from `$fillable` if present + remove `'rating' => 'decimal:2'` from `$casts`)
- Update: `api/database/factories/StoreFactory.php` (remove the `'rating' => 0` default)
- Update: `api/app/Modules/Stores/Resources/StorePublicResource.php` (drop the `'rating' => $this->rating` line)
- Update: `api/app/Modules/Admin/Resources/AdminStoreResource.php` (drop the `'rating' => $this->rating` line)
- Update: `api/app/Modules/Admin/Resources/AdminStoreDetail.php` (drop the `'rating' => $this->rating` line)
- Update: `web/src/app/(admin)/admin/stores/__tests__/stores-page.test.tsx` (remove `rating: null` from both fixture occurrences)
- Update: `web/src/app/(admin)/admin/stores/[id]/__tests__/store-detail-client.test.tsx` (remove the `rating: null` fixture line)
- Test: extend an existing `StoreApiTest` / `AdminStoreApiTest` (or whichever test file currently asserts Store-resource shape) with one assertion that `rating` is no longer in the response. If no Store-resource-shape test exists, add one assertion inside the new migration's `ReviewSchemaTest` / `StoreCounterColumnsTest` Phase-A files — do **not** create a brand-new test file for one negative assertion.

Migration:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('stores', function (Blueprint $t) {
            $t->dropColumn('rating');
        });
    }

    public function down(): void
    {
        Schema::table('stores', function (Blueprint $t) {
            // Restore the legacy column with its prior shape (decimal:2 default 0).
            $t->decimal('rating', 5, 2)->default(0)->after('total_sales');
        });
    }
};
```

> **Plan note (Architectural Call #2 — Amendment 2: clean break on legacy `stores.rating`):** The original Plan 1 declined to touch this column (see the now-rewritten Plan note above on `stores.rating`). A deeper audit found the column **is** API-exposed in three Resources (`StorePublicResource:19`, `AdminStoreResource:23`, `AdminStoreDetail:93`) — each emits `'rating' => $this->rating`. Plus `Store::$casts` has `'rating' => 'decimal:2'` and `StoreFactory` writes `'rating' => 0` as default. The column is functionally inert (always 0; never a real signal in production). **User-locked decision: drop it.** The new `average_rating` counter (added in Task 2) is the semantic replacement and is exposed via `GET /v1/stores/{store}/rating-summary`, not via the Store Resources directly. Future API consumers should switch to the rating-summary endpoint. Web side: 2 test fixture files reference `rating: null` (3 occurrences total); no production component reads it.

> **Plan note (migration ordering — `100099` slot):** Filename uses a high suffix (`_100099_`) inside the same `2026_05_13` day to ensure this migration runs **after** Tasks 1 + 2 + 2b add their new columns/tables. Postgres + SQLite both honor lexical migration ordering; this keeps the schema monotonic — adds first, then drops. If a later Plan-1 migration is inserted, bump this filename's suffix to stay last.

> **Plan note (reversible `down()`):** The `down()` re-adds the column with `decimal:2 default 0` to match its original shape so test rollbacks (and any future `migrate:refresh`) recover gracefully. Rolling forward then back leaves an empty column with the right type — adequate for dev workflows, and we don't need to preserve historical data since the values were always 0.

> **Plan note (clean update for the earlier Plan note on `stores.rating`):** The earlier Plan note at Task 2 says "Plan 1 does NOT touch it". With Amendment 2 that note is now obsolete — Phase A Task 2c **does** touch it. The Task 2 plan note has been updated in place to reference this cleanup task.

- [ ] **Step 1: Write the failing test** — extend an existing Store API test to assert the response no longer includes `rating`. Single assertion (`$this->assertArrayNotHasKey('rating', $payload['data'])`). If no such file exists, add a one-line assertion to `tests/Feature/Reviews/StoreCounterColumnsTest.php` after the existing column assertions.

- [ ] **Step 2: Run, confirm failure** (the response still has `rating` until the Resource lines come out).

- [ ] **Step 3: Implement** — migration; `$casts` / `$fillable` cleanup on `Store`; three Resource updates; `StoreFactory` update; two web fixture updates. `php artisan migrate`.

- [ ] **Step 4: Run; iterate to PASS.** Re-run the full `tests/Feature/Reviews/` suite to confirm no regressions from the schema change.

- [ ] **Step 5: Web fixture sweep** — `cd web && npm run test -- --run stores-page store-detail-client`. Both pass.

---

## Phase B — `ReviewEligibility` + `ReviewWriter` services

### Task 3: `ReviewEligibility` service

**Files:**
- Create: `api/app/Modules/Reviews/Services/ReviewEligibility.php`
- Create: `api/app/Modules/Reviews/README.md` *(optional but conventional)*
- Test: `api/tests/Feature/Reviews/ReviewEligibilityTest.php`

Service:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Services;

use App\Models\OrderItem;
use App\Models\Review;
use App\Models\User;

final class ReviewEligibility
{
    /** Returns true iff `$buyer` can create a new review on `$orderItem`. */
    public function canCreate(OrderItem $orderItem, User $buyer): bool
    {
        $orderItem->loadMissing('order.purchase');

        if ((string) $orderItem->order->purchase->buyer_id !== (string) $buyer->id) {
            return false;
        }
        if ($orderItem->order->delivered_at === null) {
            return false;
        }
        if (Review::query()->where('order_item_id', $orderItem->id)->exists()) {
            return false;
        }

        return true;
    }

    /** Aborts inline with the right HTTP code if the buyer cannot create. */
    public function assertCanCreate(OrderItem $orderItem, User $buyer): void
    {
        $orderItem->loadMissing('order.purchase');

        if ((string) $orderItem->order->purchase->buyer_id !== (string) $buyer->id) {
            abort(403, 'You can only review items you purchased.');
        }
        if ($orderItem->order->delivered_at === null) {
            abort(422, 'Order has not been delivered yet.');
        }
        if (Review::query()->where('order_item_id', $orderItem->id)->exists()) {
            abort(409, 'A review already exists for this item.');
        }
    }

    /** Returns true iff `$user` can edit `$review` right now. */
    public function canEdit(Review $review, User $user): bool
    {
        if ((string) $review->reviewer_user_id !== (string) $user->id) {
            return false;
        }
        if (! $review->isWithinEditWindow()) {
            return false;
        }

        return true;
    }

    public function assertCanEdit(Review $review, User $user): void
    {
        if ((string) $review->reviewer_user_id !== (string) $user->id) {
            abort(403, 'You can only edit your own review.');
        }
        if (! $review->isWithinEditWindow()) {
            abort(422, 'Review can no longer be edited (30-day window expired).');
        }
    }
}
```

> **Plan note (inline `abort()` vs typed exceptions) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Layers 10 and 11 both use inline `abort(403/422/409, '…')` in services (confirmed by reading `ProactiveReturnService.php` lines 146–165). Plan 1 follows the same convention. Pros: zero extra ceremony, no exception-handler wiring needed, integration tests assert HTTP codes directly. Cons: services that abort are not pure (they have an HTTP-aware coupling); for a code path that could ever be invoked from a CLI command, the pattern is wrong. **Reviews are HTTP-only entry points** in v1 (no Artisan command writes reviews), so the trade is fine. The `canCreate`/`canEdit` boolean methods exist alongside for non-HTTP callers (e.g., the controller's `index` method may want to return an `is_eligible` flag in the JSON without aborting).

> **Plan note (`(string)` casts on UUID comparison):** Eloquent UUIDs come back as PHP strings, but `$user->id` and `$buyer->id` are both already strings — the cast is defensive against a future column type change. Cheap, no downside.

- [ ] **Step 1: Write the failing test** — `ReviewEligibilityTest.php`:

```php
public function test_can_create_returns_true_for_buyer_of_delivered_order_without_existing_review(): void
public function test_can_create_returns_false_for_non_buyer(): void
public function test_can_create_returns_false_for_undelivered_order(): void
public function test_can_create_returns_false_when_review_already_exists(): void
public function test_assert_can_create_aborts_403_for_non_buyer(): void
public function test_assert_can_create_aborts_422_for_undelivered_order(): void
public function test_assert_can_create_aborts_409_for_duplicate(): void
public function test_can_edit_returns_true_for_owner_within_window(): void
public function test_can_edit_returns_false_for_non_owner(): void
public function test_can_edit_returns_false_after_30_days(): void
// (Carbon::setTestNow 31 days past created_at)
public function test_assert_can_edit_aborts_403_for_non_owner(): void
public function test_assert_can_edit_aborts_422_after_30_days(): void
```

- [ ] **Step 2: Run, confirm failure** — service missing.

- [ ] **Step 3: Implement** the service. No service-provider binding needed; Laravel auto-resolves the no-arg class.

- [ ] **Step 4: Run; iterate to 12/12 PASS.**

---

### Task 4: `ReviewWriter` service + `NewReviewInput` / `EditReviewInput` DTOs

**Files:**
- Create: `api/app/Modules/Reviews/Services/ReviewWriter.php`
- Create: `api/app/Modules/Reviews/DTOs/NewReviewInput.php`
- Create: `api/app/Modules/Reviews/DTOs/EditReviewInput.php`
- Test: `api/tests/Feature/Reviews/ReviewWriterTest.php`

DTOs (readonly value objects; the FormRequest hydrates them):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\DTOs;

final readonly class NewReviewInput
{
    /**
     * @param  list<string>  $attachmentIds  pre-uploaded ReviewAttachmentUpload ids from `POST /v1/order-items/{order_item}/review-attachments`; 0–4.
     */
    public function __construct(
        public int $rating,
        public ?int $ratingItemAsDescribed,    // null → coerced to $rating at write
        public ?int $ratingShippingSpeed,
        public ?int $ratingCommunication,
        public ?int $ratingPackaging,
        public ?string $title,
        public string $body,
        public array $attachmentIds = [],
    ) {}
}

final readonly class EditReviewInput
{
    /**
     * @param  list<string>  $attachmentIds  pre-uploaded ids to ADD to the review on top of existing media.
     *                                       Existing media is preserved unless explicitly removed (Plan 3 may add removal; Plan 1 = append-only edits).
     */
    public function __construct(
        public int $rating,
        public ?int $ratingItemAsDescribed,
        public ?int $ratingShippingSpeed,
        public ?int $ratingCommunication,
        public ?int $ratingPackaging,
        public ?string $title,
        public string $body,
        public array $attachmentIds = [],
    ) {}
}
```

Writer:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Services;

use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Review;
use App\Models\ReviewAttachmentUpload;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\ReviewPublishedNotification;
use App\Modules\Notifications\Notifications\ReviewReceivedNotification;
use App\Modules\Reviews\DTOs\EditReviewInput;
use App\Modules\Reviews\DTOs\NewReviewInput;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;

final class ReviewWriter
{
    public function create(OrderItem $orderItem, User $buyer, NewReviewInput $input): Review
    {
        $orderItem->loadMissing('order.store.owner');

        return DB::transaction(function () use ($orderItem, $buyer, $input) {
            // Lock the store row for the duration of the recompute so concurrent
            // review writers can't both read the same aggregate and clobber each other.
            $store = Store::query()->whereKey($orderItem->order->store_id)->lockForUpdate()->firstOrFail();

            // Defence-in-depth: re-check uniqueness inside the transaction. The
            // controller's eligibility guard already runs, but a concurrent racer
            // could in theory slip between guard and persist. Pre-check is cheaper
            // than catching a unique-constraint violation by name.
            if (Review::query()->where('order_item_id', $orderItem->id)->exists()) {
                abort(409, 'A review already exists for this item.');
            }

            $review = Review::query()->create([
                'id' => (string) Str::uuid(),
                'order_item_id' => $orderItem->id,
                'order_id' => $orderItem->order_id,
                'store_id' => $store->id,
                'reviewer_user_id' => $buyer->id,
                'rating' => $input->rating,
                // Default-dimension-to-overall posture: any null becomes the overall.
                'rating_item_as_described' => $input->ratingItemAsDescribed ?? $input->rating,
                'rating_shipping_speed' => $input->ratingShippingSpeed ?? $input->rating,
                'rating_communication' => $input->ratingCommunication ?? $input->rating,
                'rating_packaging' => $input->ratingPackaging ?? $input->rating,
                'title' => $input->title,
                'body' => $input->body,
                'state' => 'visible',
            ]);

            if (! empty($input->attachmentIds)) {
                $this->attachMedia($review, $buyer, $orderItem, $input->attachmentIds);
            }

            $this->recomputeAggregates($store);

            // Notifications dispatched after commit (ShouldQueue defers actual send
            // to the queue worker; the dispatch call itself can sit in the transaction
            // because Laravel's transaction-aware events handle this correctly).
            Notification::send($buyer, new ReviewPublishedNotification($review));
            if ($store->owner) {
                Notification::send($store->owner, new ReviewReceivedNotification($review));
            }

            return $review->fresh(['orderItem', 'reviewer', 'store', 'media']);
        });
    }

    public function update(Review $review, EditReviewInput $input): Review
    {
        $review->loadMissing('store.owner', 'order');

        return DB::transaction(function () use ($review, $input) {
            $store = Store::query()->whereKey($review->store_id)->lockForUpdate()->firstOrFail();

            $review->update([
                'rating' => $input->rating,
                'rating_item_as_described' => $input->ratingItemAsDescribed ?? $input->rating,
                'rating_shipping_speed' => $input->ratingShippingSpeed ?? $input->rating,
                'rating_communication' => $input->ratingCommunication ?? $input->rating,
                'rating_packaging' => $input->ratingPackaging ?? $input->rating,
                'title' => $input->title,
                'body' => $input->body,
                'edited_at' => now(),
            ]);

            if (! empty($input->attachmentIds)) {
                $review->loadMissing('reviewer', 'orderItem');
                $this->attachMedia($review, $review->reviewer, $review->orderItem, $input->attachmentIds);
            }

            $this->recomputeAggregates($store);

            return $review->fresh(['orderItem', 'reviewer', 'store', 'media']);
        });
    }

    /**
     * Recompute the six denormalized counters on the store from scratch over the
     * current set of visible reviews. Called from inside the transaction; the
     * store row is already locked by the caller.
     */
    private function recomputeAggregates(Store $store): void
    {
        $reviews = $store->reviews()->where('state', 'visible')->get();
        $count = $reviews->count();

        $store->update([
            'review_count' => $count,
            'average_rating' => $count > 0 ? (float) round($reviews->avg('rating'), 2) : null,
            'avg_item_as_described' => $count > 0 ? (float) round($reviews->avg('rating_item_as_described'), 2) : null,
            'avg_shipping_speed' => $count > 0 ? (float) round($reviews->avg('rating_shipping_speed'), 2) : null,
            'avg_communication' => $count > 0 ? (float) round($reviews->avg('rating_communication'), 2) : null,
            'avg_packaging' => $count > 0 ? (float) round($reviews->avg('rating_packaging'), 2) : null,
        ]);
    }

    /**
     * Re-parents staged media from a `ReviewAttachmentUpload` (created via
     * the dedicated `POST /v1/order-items/{order_item}/review-attachments`
     * endpoint) onto the final Review's `review_attachments` collection.
     * Mirrors `MessagePoster::attachMedia` line-for-line; the only differences
     * are (a) the model is `ReviewAttachmentUpload` not `MessageAttachmentUpload`,
     * (b) ownership is scoped by `(order_item_id, user_id)` not `(order_id, user_id)`.
     *
     * @param  list<string>  $attachmentIds
     */
    private function attachMedia(Review $review, User $author, OrderItem $orderItem, array $attachmentIds): void
    {
        if (count($attachmentIds) > 4) {
            abort(422, 'Up to 4 photos per review.');
        }

        $uploads = ReviewAttachmentUpload::query()
            ->whereIn('id', $attachmentIds)
            ->where('user_id', $author->id)
            ->where('order_item_id', $orderItem->id)
            ->get();

        if ($uploads->count() !== count($attachmentIds)) {
            abort(422, 'One or more attachments are invalid.');
        }

        foreach ($uploads as $upload) {
            foreach ($upload->getMedia('staged') as $media) {
                $media->move($review, 'review_attachments');
            }
            $upload->delete();
        }
    }
}
```

> **Plan note (aggregate recompute strategy: from-scratch in PHP) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two reasonable strategies for keeping the store counters accurate:
> - **(a) From-scratch recompute in PHP** (Plan 1's choice). On every create/edit, `$store->reviews()->where('state','visible')->get()` + `Collection::avg()`. Per-store bounded review count (a typical Alqove seller has a few hundred at most) makes the SELECT + in-memory mean cheap. Simpler to reason about than incremental delta accounting, especially around edits where the rating may have moved from 3 to 5. Correct under concurrent writes when wrapped in a `DB::transaction` with `Store::lockForUpdate()` (which Plan 1 does).
> - **(b) Incremental delta accounting.** Maintain `(sum, count)` pairs and update them per write. Faster for large per-store volumes, but error-prone around edits and hides; would also need extra columns or a separate aggregate table.
> **Going with (a).** Plan 3 may revisit if a single store ever ships ~10k reviews and the SELECT becomes meaningful — at that point a `(sum_rating, sum_item_as_described, …, count)` materialised aggregate becomes worth the complexity. Plan 1's volumes don't justify it.

> **Plan note (`lockForUpdate` on the Store row):** Pessimistic row-level lock for the duration of the transaction. Concurrent writers serialize on this lock, so the read-modify-write of the six counters is atomic. Postgres releases the lock at commit. No deadlock potential because the only lock ordering is store-then-review-insert and every code path follows that order.

> **Plan note (in-transaction uniqueness re-check):** Belt-and-suspenders. The DB unique constraint on `reviews.order_item_id` is the ultimate guard; the controller-side eligibility check is the primary; the in-transaction re-check turns a noisy unique-constraint violation into a tidy 409 with a readable message. Cost: one extra `SELECT 1 FROM reviews WHERE order_item_id = ? LIMIT 1` per create. Negligible.

> **Plan note (round to 2 decimals):** `round($avg, 2)` matches what the spec implies for the badge display ("★ 4.8"). The float column stores 4.83333…; the API resource exposes the rounded value (also rounded at the DB write time so the DB column itself is clean). Plan 3 may revisit if marketing wants 1-decimal display ("★ 4.8" rounded down vs 4.83 → "4.8") — that's a presentation concern, but starting with 2-decimal storage keeps both options open.

> **Plan note (notifications inside the transaction):** Laravel's `Notification::send` on `ShouldQueue` notifications enqueues a job; the actual database write of the `notifications` row happens via the queue worker. The enqueue itself is cheap and safe inside the transaction. If the transaction rolls back, the queue job will still fire, then the worker will try to load the now-non-existent Review and error out — but the rollback case in this writer only happens on `abort()` from the uniqueness re-check, which fires *before* the notification send. So the notifications are correctly gated on commit. If Plan 2's hide path needs to fire a notification only on commit, use Laravel's `DB::afterCommit` callback wrapper. Not needed in Plan 1.

> **Plan note (dedicated `ReviewAttachmentUpload` endpoint — Architectural Call #1, RESOLVED):** The original draft of this plan reused Layer 9's `POST /v1/orders/{order}/messages/attachments` endpoint with collection name `review_attachments` — semantically awkward (URL is order/messages-scoped but used for reviews) and forced a cross-domain dependency where `MessagePoster::attachMedia` and `ReviewWriter::attachMedia` queried the same `message_attachment_uploads` table. **User-locked decision: ship a dedicated endpoint.** Plan 1 now adds a parallel `review_attachment_uploads` table, `ReviewAttachmentUpload` model, `ReviewAttachmentUploadFactory`, `ReviewAttachmentUploadController`, and `POST /v1/order-items/{order_item}/review-attachments` route — see Task 2b (Phase A schema) for the migration + model + factory and Task 5b (Phase C endpoints) for the controller + route. The endpoint is order-item-scoped (vs order-scoped for messages) because review attachments belong to a specific reviewable item, and this matches the Review's own `order_item_id` FK. All validation rules, the Spatie `staged` collection name, and the response shape mirror Layer 9 exactly. **No deferred rename in Plan 3** — the messaging table keeps its messaging-specific name; reviews now own a clean parallel surface. The cross-domain coupling line in the "Open items deferred to Plan 2 (or later)" section has been removed.

- [ ] **Step 1: Write the failing test** — `ReviewWriterTest.php`:

```php
public function test_create_writes_review_with_explicit_dimensions(): void
public function test_create_defaults_omitted_dimensions_to_overall(): void
// (input dimensions = null,null,null,null; assert all four columns == rating)
public function test_create_with_partial_dimensions_preserves_explicit_ones(): void
// (input rating=5, item_as_described=3, others null; assert item_as_described=3, others=5)
public function test_create_recomputes_store_aggregates(): void
// (single review → store.average_rating == rating, store.review_count == 1)
public function test_create_recomputes_with_two_reviews_averages(): void
// (two reviews on same store; store.average_rating == mean rounded to 2 decimals)
public function test_create_with_attachments_moves_media_to_review_collection(): void
public function test_create_rejects_more_than_four_attachments(): void
public function test_create_rejects_attachments_owned_by_wrong_user(): void
public function test_create_rejects_attachments_from_wrong_order(): void
public function test_create_aborts_409_on_duplicate_via_in_transaction_recheck(): void
// (manually pre-insert a review for the same order_item; expect abort 409)
public function test_create_dispatches_buyer_notification(): void
// (Notification::fake; assertSentTo $buyer, ReviewPublishedNotification)
public function test_create_dispatches_seller_notification(): void
// (assertSentTo $store->owner, ReviewReceivedNotification)
public function test_update_recomputes_aggregates_after_rating_change(): void
// (review.rating goes from 3 to 5; store.average_rating moves)
public function test_update_stamps_edited_at(): void
public function test_update_preserves_created_at(): void
// (created_at unchanged across edit)
public function test_update_with_null_dimensions_coerces_to_overall(): void
public function test_update_with_new_attachments_appends_to_existing_media(): void
public function test_aggregates_round_to_two_decimals(): void
public function test_aggregates_are_null_when_no_visible_reviews(): void
// (create then …no, Plan 1 has no hide — assert default state where no reviews → counters null/0)
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement** the DTOs and the service. Tests that need to assert notification dispatch should use `Notification::fake()` in `setUp` or per-test.

- [ ] **Step 4: Run; iterate to ~19/19 PASS.**

---

## Phase C — Endpoints

### Task 5: `POST /v1/order-items/{order_item}/reviews`

**Files:**
- Create: `api/app/Modules/Reviews/Controllers/ReviewController.php`
- Create: `api/app/Modules/Reviews/Requests/CreateReviewRequest.php`
- Create: `api/app/Modules/Reviews/Resources/ReviewResource.php`
- Create: `api/app/Modules/Reviews/routes.php`
- Update: `api/routes/api.php` (require the new module routes file)
- Test: `api/tests/Feature/Reviews/CreateReviewEndpointTest.php`

`CreateReviewRequest`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Requests;

use App\Modules\Reviews\DTOs\NewReviewInput;
use Illuminate\Foundation\Http\FormRequest;

class CreateReviewRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;  // controller delegates to ReviewEligibility for the real check.
    }

    public function rules(): array
    {
        return [
            'rating' => ['required', 'integer', 'min:1', 'max:5'],
            'rating_item_as_described' => ['nullable', 'integer', 'min:1', 'max:5'],
            'rating_shipping_speed' => ['nullable', 'integer', 'min:1', 'max:5'],
            'rating_communication' => ['nullable', 'integer', 'min:1', 'max:5'],
            'rating_packaging' => ['nullable', 'integer', 'min:1', 'max:5'],
            'title' => ['nullable', 'string', 'max:120'],
            'body' => ['required', 'string', 'min:20', 'max:2000'],
            'attachment_ids' => ['nullable', 'array', 'max:4'],
            'attachment_ids.*' => ['string', 'uuid'],
        ];
    }

    public function toInput(): NewReviewInput
    {
        $v = $this->validated();
        return new NewReviewInput(
            rating: $v['rating'],
            ratingItemAsDescribed: $v['rating_item_as_described'] ?? null,
            ratingShippingSpeed: $v['rating_shipping_speed'] ?? null,
            ratingCommunication: $v['rating_communication'] ?? null,
            ratingPackaging: $v['rating_packaging'] ?? null,
            title: $v['title'] ?? null,
            body: $v['body'],
            attachmentIds: $v['attachment_ids'] ?? [],
        );
    }
}
```

`ReviewResource`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class ReviewResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'order_item_id' => $this->order_item_id,
            'order_id' => $this->order_id,
            'store_id' => $this->store_id,
            'reviewer' => [
                'id' => $this->reviewer_user_id,
                'name' => $this->reviewer?->name,
            ],
            'rating' => $this->rating,
            'dimensions' => [
                'item_as_described' => $this->rating_item_as_described,
                'shipping_speed' => $this->rating_shipping_speed,
                'communication' => $this->rating_communication,
                'packaging' => $this->rating_packaging,
            ],
            'title' => $this->title,
            'body' => $this->body,
            'state' => $this->state,
            'photos' => $this->getMedia('review_attachments')->map(fn ($m) => [
                'id' => (string) $m->id,
                'url' => $m->getUrl(),
                'content_type' => $m->mime_type,
            ])->values(),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
            'edited_at' => $this->edited_at?->toIso8601String(),
            'is_within_edit_window' => $this->isWithinEditWindow(),
        ];
    }
}
```

`ReviewController`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Controllers;

use App\Models\OrderItem;
use App\Models\Review;
use App\Modules\Reviews\Requests\CreateReviewRequest;
use App\Modules\Reviews\Requests\UpdateReviewRequest;
use App\Modules\Reviews\Resources\ReviewResource;
use App\Modules\Reviews\Services\ReviewEligibility;
use App\Modules\Reviews\Services\ReviewWriter;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class ReviewController
{
    public function __construct(
        private readonly ReviewEligibility $eligibility,
        private readonly ReviewWriter $writer,
    ) {}

    public function store(CreateReviewRequest $request, OrderItem $orderItem): ReviewResource
    {
        $this->eligibility->assertCanCreate($orderItem, $request->user());
        $review = $this->writer->create($orderItem, $request->user(), $request->toInput());

        return (new ReviewResource($review))->additional(['_status' => 201]);
    }

    public function update(UpdateReviewRequest $request, Review $review): ReviewResource
    {
        $this->eligibility->assertCanEdit($review, $request->user());
        $updated = $this->writer->update($review, $request->toInput());

        return new ReviewResource($updated);
    }

    public function myReviews(Request $request): AnonymousResourceCollection
    {
        $reviews = Review::query()
            ->where('reviewer_user_id', $request->user()->id)
            ->with(['reviewer', 'store', 'media'])
            ->orderByDesc('created_at')
            ->paginate(20);

        return ReviewResource::collection($reviews);
    }
}
```

Routes:

```php
<?php
// api/app/Modules/Reviews/routes.php

declare(strict_types=1);

use App\Modules\Reviews\Controllers\PublicReviewController;
use App\Modules\Reviews\Controllers\ReviewAttachmentUploadController;
use App\Modules\Reviews\Controllers\ReviewController;
use Illuminate\Support\Facades\Route;

// PUBLIC — no auth required (matches the convention in Stores/routes.php + Items/routes.php).
Route::get('/stores/{store}/reviews', [PublicReviewController::class, 'forStore']);
Route::get('/stores/{store}/rating-summary', [PublicReviewController::class, 'ratingSummary']);

// AUTHENTICATED — buyer write surfaces.
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/order-items/{order_item}/reviews', [ReviewController::class, 'store']);
    Route::patch('/reviews/{review}', [ReviewController::class, 'update']);
    Route::post('/order-items/{order_item}/review-attachments', [ReviewAttachmentUploadController::class, 'store']);
    Route::get('/me/reviews', [ReviewController::class, 'myReviews']);
});
```

And in `api/routes/api.php` add `require app_path('Modules/Reviews/routes.php');` alongside the existing `require` lines.

> **Plan note (route ordering — public outside auth group):** Confirmed by reading `api/app/Modules/Stores/routes.php` lines 11–13 and `api/app/Modules/Items/routes.php` lines 9–11: public routes register directly at top level and authenticated routes go in the `Route::middleware('auth:sanctum')->group(...)` block. Plan 1 follows the same pattern. The two new public review endpoints are reachable without a bearer token; integration tests verify this explicitly with `$this->getJson(...)` (no `actingAs`).

> **Plan note (`additional(['_status' => 201])` vs `response()->json(..., 201)`):** Laravel's `JsonResource` defaults to 200; for the create endpoint we want 201. Two patterns work: (a) return `(new ReviewResource(...))->response()->setStatusCode(201)` — verbose; (b) override `toResponse` on the resource — overkill. Plan 1 uses approach (a). **If the codebase has a precedent** (check Layer 11's controllers), match it. Audit: `SellerBalanceController::show` returns `response()->json(['data' => ...])` directly; this layer is consistent with that style for the create endpoint:
>
> ```php
> public function store(...): JsonResponse {
>     // ...
>     return response()->json(['data' => new ReviewResource($review)], 201);
> }
> ```
> Plan 1 uses the `response()->json(...)` pattern to match Layer 11.

- [ ] **Step 1: Write the failing test** — `CreateReviewEndpointTest.php`:

```php
public function test_post_review_returns_201_with_review_payload(): void
public function test_post_review_requires_authentication(): void
public function test_post_review_returns_403_when_caller_is_not_the_buyer(): void
public function test_post_review_returns_422_when_order_not_delivered(): void
public function test_post_review_returns_409_when_review_already_exists(): void
public function test_post_review_validates_rating_required(): void
public function test_post_review_validates_rating_in_1_to_5(): void
public function test_post_review_validates_body_min_20_chars(): void
public function test_post_review_validates_body_max_2000_chars(): void
public function test_post_review_validates_title_max_120_chars(): void
public function test_post_review_validates_attachment_ids_max_4(): void
public function test_post_review_default_dimensions_to_overall_when_omitted(): void
public function test_post_review_with_explicit_dimensions_persists_them(): void
public function test_post_review_with_attachments_moves_media(): void
public function test_post_review_aggregate_updates_atomically(): void
// (assert store.average_rating + review_count after a successful POST)
public function test_post_review_fires_both_notifications(): void
public function test_response_payload_includes_photos_array(): void
public function test_response_payload_includes_is_within_edit_window_true(): void
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement** the request, resource, controller, routes. Register the module routes file in `api/routes/api.php`.

- [ ] **Step 4: Run; iterate to ~18/18 PASS.**

---

### Task 5b: `POST /v1/order-items/{order_item}/review-attachments` (dedicated upload)

**Files:**
- Create: `api/app/Modules/Reviews/Controllers/ReviewAttachmentUploadController.php`
- Create: `api/app/Modules/Reviews/Requests/UploadReviewAttachmentRequest.php`
- Update: `api/app/Modules/Reviews/routes.php` (already added in Task 5's route block — confirm the line is present)
- Test: `api/tests/Feature/Reviews/UploadReviewAttachmentEndpointTest.php`

`UploadReviewAttachmentRequest` (mirrors `App\Modules\Messaging\Requests\UploadAttachmentRequest`):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadReviewAttachmentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // controller handles ownership + delivered-order checks.
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'file' => [
                'required',
                'file',
                'mimes:jpeg,jpg,png,heic',
                'max:5120',
            ],
        ];
    }
}
```

`ReviewAttachmentUploadController` (mirrors `App\Modules\Messaging\Controllers\AttachmentController::store` line-for-line; differences: scoped to `OrderItem`, ownership check chains through `$orderItem->order->purchase->buyer_id`, optional `delivered_at` pre-check):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Controllers;

use App\Models\OrderItem;
use App\Models\ReviewAttachmentUpload;
use App\Modules\Reviews\Requests\UploadReviewAttachmentRequest;
use Illuminate\Http\JsonResponse;

class ReviewAttachmentUploadController
{
    public function store(UploadReviewAttachmentRequest $request, OrderItem $orderItem): JsonResponse
    {
        $user = $request->user();

        // Ownership: the authed user must be the buyer-of-record on the
        // chain `$orderItem->order->purchase->buyer_id`.
        $orderItem->loadMissing('order.purchase');
        if ((string) $orderItem->order->purchase->buyer_id !== (string) $user->id) {
            abort(403, 'Not your purchase.');
        }

        // Pre-check: the order must be delivered. Buyers cannot stage review
        // photos before delivery (no point — they couldn't submit a review yet).
        if ($orderItem->order->delivered_at === null) {
            abort(422, 'Order is not yet delivered.');
        }

        $upload = ReviewAttachmentUpload::query()->create([
            'order_item_id' => $orderItem->id,
            'user_id' => $user->id,
        ]);

        $file = $request->file('file');
        $media = $upload->addMedia($file->getRealPath())
            ->usingFileName($file->getClientOriginalName())
            ->toMediaCollection('staged');

        return response()->json([
            'data' => [
                'id' => $upload->id,
                'url' => $media->getUrl(),
                'thumb_url' => $media->hasGeneratedConversion('thumb')
                    ? $media->getUrl('thumb')
                    : $media->getUrl(),
                'content_type' => $media->mime_type,
                'size_bytes' => $media->size,
            ],
        ], 201);
    }
}
```

> **Plan note (`thumb_url` parity vs Layer 9):** Layer 9's `AttachmentController` does not currently emit `thumb_url`. Plan 1 emits it (falling back to the original URL when no `thumb` conversion is registered) because the `<ReviewPhotoUploader>` UI shows thumbnails in the staged-upload preview row. Spatie auto-generates thumbnails only if a conversion is registered on the model; we do not register one on `ReviewAttachmentUpload` in Plan 1, so `thumb_url` defaults to `getUrl()`. Plan 3 may register a `thumb` conversion for both `ReviewAttachmentUpload` and `MessageAttachmentUpload` as a polish pass.

> **Plan note (ownership check mirrors `MessageThreadAccess`):** Layer 9 delegates the access check to `App\Modules\Messaging\Services\MessageThreadAccess::canAccess(User, Order)`. The review-attachment equivalent is a single ownership predicate `$orderItem->order->purchase->buyer_id === $user->id` — no shared multi-party access. Inlining it in the controller keeps the surface small; a future `ReviewAccess` service can be extracted if the gating becomes more complex (e.g., if Plan 2's moderation lets an admin stage attachments on behalf of a buyer — unlikely).

> **Plan note (no admin override / no seller upload):** Reviews are buyer-authored only; the upload endpoint does not have an admin or seller-impersonation code path. If a future need arises (e.g., admin attaching evidence to a hidden-review moderation note), that lives in a Plan-2 admin endpoint, not here.

- [ ] **Step 1: Write the failing tests** — `UploadReviewAttachmentEndpointTest.php`:

```php
public function test_upload_returns_201_with_id_url_and_thumb_url(): void
public function test_upload_creates_review_attachment_upload_row(): void
public function test_upload_attaches_media_to_staged_collection(): void
public function test_upload_requires_authentication(): void
public function test_upload_returns_403_when_caller_is_not_the_buyer(): void
public function test_upload_returns_422_when_order_not_delivered(): void
public function test_upload_validates_file_required(): void
public function test_upload_validates_mime_types(): void
// (pdf upload should be rejected; jpeg accepted)
public function test_upload_validates_max_file_size(): void
public function test_create_review_rejects_attachment_id_owned_by_another_user(): void
// (Buyer A uploads → Buyer B tries to submit a review citing that attachment_id → 422)
public function test_create_review_rejects_attachment_id_from_a_different_order_item(): void
// (Buyer uploads under order_item X, tries to submit review on order_item Y citing that id → 422)
```

- [ ] **Step 2: Run, confirm failure** — controller does not exist yet.

- [ ] **Step 3: Implement** the request + controller. Route registration was added in Task 5's `routes.php` block; double-check the line is present.

- [ ] **Step 4: Run; iterate to ~10/10 PASS.** The last two assertions exercise the writer's `attachMedia` guard (which already exists from Task 4 but now reads from `review_attachment_uploads` instead of `message_attachment_uploads`).

---

### Task 6: `PATCH /v1/reviews/{review}` (buyer edit)

**Files:**
- Create: `api/app/Modules/Reviews/Requests/UpdateReviewRequest.php`
- Update: `api/app/Modules/Reviews/Controllers/ReviewController.php` *(already in Task 5; included here for clarity)*
- Test: `api/tests/Feature/Reviews/UpdateReviewEndpointTest.php`

`UpdateReviewRequest` mirrors `CreateReviewRequest` rule-for-rule (PATCH is full-replace of the same shape — edits replace the whole review, not partial-update individual fields). Differences:
- `attachment_ids.*` still validates as uuid; new ids are appended to existing review media (Plan 1 cannot remove existing media from a review).

```php
public function rules(): array
{
    return [
        'rating' => ['required', 'integer', 'min:1', 'max:5'],
        'rating_item_as_described' => ['nullable', 'integer', 'min:1', 'max:5'],
        'rating_shipping_speed' => ['nullable', 'integer', 'min:1', 'max:5'],
        'rating_communication' => ['nullable', 'integer', 'min:1', 'max:5'],
        'rating_packaging' => ['nullable', 'integer', 'min:1', 'max:5'],
        'title' => ['nullable', 'string', 'max:120'],
        'body' => ['required', 'string', 'min:20', 'max:2000'],
        'attachment_ids' => ['nullable', 'array', 'max:4'],
        'attachment_ids.*' => ['string', 'uuid'],
    ];
}
```

> **Plan note (PATCH vs PUT semantics):** Spec uses PATCH; Plan 1 honours that, but the body shape is a full replacement of the review's editable fields. Treating it as "PATCH-shaped PUT" matches Layer 8's `PATCH /v1/admin/disputes/{id}` pattern. Partial-field updates ("change only the body, leave the rating alone") are not supported in v1 — the UI sends all fields on every submit so this is fine.

> **Plan note (existing media never removed in Plan 1):** A buyer who wants to remove a photo has no way to do so in Plan 1 — they can only add more (up to 4 total cap is enforced). Edge case: if they upload 4 photos initially and try to add more on edit, the writer's `count > 4` guard fires correctly **only on the new batch**; total media is not checked at the writer level. **Plan 1 leaves this gap explicit:** the UI client-side gates total media count, the server gates batch count. Plan 3 can tighten if needed (add a server-side total cap).

- [ ] **Step 1: Write the failing test** — `UpdateReviewEndpointTest.php`:

```php
public function test_patch_review_returns_200_with_updated_payload(): void
public function test_patch_review_returns_403_for_non_owner(): void
public function test_patch_review_returns_422_after_30_day_window(): void
// (Carbon::setTestNow created_at + 31d)
public function test_patch_review_stamps_edited_at_on_first_edit(): void
public function test_patch_review_stamps_edited_at_on_subsequent_edits(): void
public function test_patch_review_recomputes_store_aggregate_after_rating_change(): void
public function test_patch_review_validates_body_length_same_as_create(): void
public function test_patch_review_default_dimensions_to_overall_when_omitted(): void
public function test_patch_review_response_includes_is_within_edit_window(): void
public function test_patch_review_with_new_attachments_appends(): void
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run; iterate to ~10/10 PASS.**

---

### Task 7: `GET /v1/me/reviews` (buyer's own paginated list)

**Files:**
- Test: `api/tests/Feature/Reviews/MyReviewsEndpointTest.php`
  *(controller method already added in Task 5)*

```php
public function test_me_reviews_returns_own_reviews_paginated(): void
public function test_me_reviews_orders_newest_first(): void
public function test_me_reviews_does_not_leak_other_buyers_reviews(): void
public function test_me_reviews_requires_authentication(): void
public function test_me_reviews_paginates_at_20_per_page(): void
public function test_me_reviews_includes_photos_in_payload(): void
public function test_me_reviews_includes_store_id_for_navigation(): void
```

- [ ] **Step 1: Write the test.**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: No code change needed if Task 5's controller already covers it; verify.**
- [ ] **Step 4: Run; iterate to 7/7 PASS.**

---

### Task 8: `GET /v1/stores/{store}/reviews` + `GET /v1/stores/{store}/rating-summary` (public)

**Files:**
- Create: `api/app/Modules/Reviews/Controllers/PublicReviewController.php`
- Test: `api/tests/Feature/Reviews/PublicStoreReviewsEndpointTest.php`
- Test: `api/tests/Feature/Reviews/RatingSummaryEndpointTest.php`

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Controllers;

use App\Models\Review;
use App\Models\Store;
use App\Modules\Reviews\Resources\ReviewResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class PublicReviewController
{
    public function forStore(Store $store): AnonymousResourceCollection
    {
        $reviews = $store->reviews()
            ->where('state', 'visible')
            ->with(['reviewer', 'media'])
            ->orderByDesc('created_at')
            ->paginate(20);

        return ReviewResource::collection($reviews);
    }

    public function ratingSummary(Store $store): JsonResponse
    {
        // The denormalized counters on $store are kept current by ReviewWriter;
        // we read them straight off the model. Distribution requires a single
        // GROUP BY over visible reviews — small per-store volume, fine to do live.
        $distribution = $store->reviews()
            ->where('state', 'visible')
            ->selectRaw('rating, COUNT(*) AS c')
            ->groupBy('rating')
            ->pluck('c', 'rating')
            ->toArray();

        // Always return all five buckets, filling zero where absent.
        $fullDistribution = [
            1 => (int) ($distribution[1] ?? 0),
            2 => (int) ($distribution[2] ?? 0),
            3 => (int) ($distribution[3] ?? 0),
            4 => (int) ($distribution[4] ?? 0),
            5 => (int) ($distribution[5] ?? 0),
        ];

        return response()->json([
            'data' => [
                'store_id' => $store->id,
                'average_rating' => $store->average_rating,
                'review_count' => (int) $store->review_count,
                'distribution' => $fullDistribution,
                'dimensions' => [
                    'item_as_described' => $store->avg_item_as_described,
                    'shipping_speed' => $store->avg_shipping_speed,
                    'communication' => $store->avg_communication,
                    'packaging' => $store->avg_packaging,
                ],
            ],
        ]);
    }
}
```

> **Plan note (`pluck('c', 'rating')` not Postgres-divergent):** Confirmed safe on both Postgres and SQLite — Laravel's query builder pluck of a count works identically. The Postgres-divergence trap from Layer 11 was `value(DB::raw($expr))` on a single-row aggregate. The Plan 1 code uses `selectRaw('rating, COUNT(*) AS c')` + `groupBy('rating')` + `pluck('c','rating')` which is portable.

> **Plan note (returning all 5 buckets even when zero):** Frontend rendering of a distribution histogram needs all five buckets present; absent keys force the client to `?? 0` everywhere. Server-side fill keeps the JSON shape stable and self-documenting.

> **Plan note (no auth on these two endpoints):** Verified by route placement — they live outside the `auth:sanctum` group. Integration tests assert this with `$this->getJson('/v1/stores/{id}/reviews')` (no `actingAs`) and expect 200.

- [ ] **Step 1: Write the failing tests** — `PublicStoreReviewsEndpointTest.php`:

```php
public function test_get_store_reviews_returns_200_without_auth(): void
public function test_get_store_reviews_orders_newest_first(): void
public function test_get_store_reviews_paginates_at_20(): void
public function test_get_store_reviews_excludes_other_stores_reviews(): void
public function test_get_store_reviews_includes_reviewer_name_and_photos(): void
public function test_get_store_reviews_includes_edited_at_when_present(): void
public function test_get_store_reviews_returns_empty_when_no_reviews(): void
```

And `RatingSummaryEndpointTest.php`:

```php
public function test_rating_summary_returns_200_without_auth(): void
public function test_rating_summary_returns_nulls_for_store_with_no_reviews(): void
public function test_rating_summary_returns_average_and_count_for_store_with_reviews(): void
public function test_rating_summary_returns_distribution_with_all_five_buckets(): void
// (create reviews at ratings 5,5,4; assert distribution[5]==2, [4]==1, others==0)
public function test_rating_summary_returns_four_dimension_averages(): void
public function test_rating_summary_average_is_rounded_to_2_decimals(): void
```

- [ ] **Step 2: Run, confirm failure.**

- [ ] **Step 3: Implement** `PublicReviewController` and the two routes.

- [ ] **Step 4: Run; iterate to ~13/13 PASS.**

---

## Phase D — Notifications

### Task 9: `NotificationCategory::Reviews` enum case + backfill

**Files:**
- Update: `api/app/Support/Enums/NotificationCategory.php` (add the case)
- Test: `api/tests/Unit/NotificationCategorySupportCaseTest.php` *(extend existing if it asserts cases)*

```php
case Reviews = 'reviews';
```

`isTransactional()` returns `false` for `Reviews` — buyers can opt out per their notification preferences.

> **Plan note (notification preferences backfill):** Existing users have rows in `notification_preferences` for the existing 10 categories. The `NotificationCategoryBackfiller` service handles adding new category rows on first-use (per existing pattern at `api/app/Modules/Notifications/Services/NotificationCategoryBackfiller.php`). Plan 1 does not need to backfill at migrate time — the gate's default-to-allowed (`$pref?->enabled ?? true`) handles users who don't have a preference row yet.

- [ ] **Step 1: Extend the existing `NotificationCategorySupportCaseTest` to assert the new case exists.**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Add the case.**
- [ ] **Step 4: Run; iterate to PASS.**

---

### Task 10: `ReviewPublishedNotification` (buyer)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/ReviewPublishedNotification.php`
- Test: `api/tests/Feature/Notifications/ReviewPublishedNotificationTest.php`

Modelled after `ReturnReceivedNotification`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Review;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class ReviewPublishedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Review $review) {}

    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Reviews,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Your review was published')
            ->from(config('mail.from.address'), 'Alqove')
            ->line('Thanks for sharing your experience — your review is live now.')
            ->action('View your review', config('app.frontend_url').'/me/reviews');
    }

    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Your review was published',
            'body' => 'Thanks for sharing your experience — your review is live now.',
            'cta_url' => '/me/reviews',
            'icon' => 'star',
            'context_type' => 'review',
            'context_id' => $this->review->id,
        ];
    }
}
```

- [ ] **Step 1: Write the failing test:**

```php
public function test_published_notification_uses_reviews_category(): void
public function test_published_notification_renders_mail_subject_and_action(): void
public function test_published_notification_database_payload_includes_review_id(): void
public function test_published_notification_respects_user_preference_opt_out(): void
// (mail channel filtered out when user disables Reviews notifications)
```

- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 4/4 PASS.**

---

### Task 11: `ReviewReceivedNotification` (seller)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/ReviewReceivedNotification.php`
- Test: `api/tests/Feature/Notifications/ReviewReceivedNotificationTest.php`

Same shape as `ReviewPublishedNotification`, addressed to the store owner:

```php
public function toMail(User $notifiable): MailMessage
{
    return (new MailMessage)
        ->subject('New review on your shop')
        ->from(config('mail.from.address'), 'Alqove')
        ->line("A buyer left a {$this->review->rating}-star review on your shop.")
        ->action('View review', config('app.frontend_url').'/seller/reviews');
}

public function toDatabase(User $notifiable): array
{
    return [
        'title' => 'New review on your shop',
        'body' => "A buyer left a {$this->review->rating}-star review on your shop.",
        'cta_url' => '/seller/reviews',
        'icon' => 'star',
        'context_type' => 'review',
        'context_id' => $this->review->id,
    ];
}
```

- [ ] **Step 1: Write the test (4 cases, same shape as Task 10).**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 4/4 PASS.**

> **Plan note (icon: `star`):** Notification bell icon mapping. The spec line 242 says "pick whatever fits the existing icon set; consult Layer 11's `banknote` precedent". `star` is the obvious choice. Frontend's `NotificationItem` component (Layer 6) maps icon strings to Lucide icons — if `star` isn't already mapped, Phase F's notification work includes adding it.

---

## Phase E — OpenAPI + types + api-client

### Task 12: OpenAPI spec

**Files:**
- Update: `api/contracts/openapi.yaml`

Add six paths + five schemas (the new upload endpoint adds one path + one `ReviewAttachmentUpload` schema; Amendment 1). Path skeletons (full request/response shape inlined below where it's load-bearing):

```yaml
/v1/order-items/{order_item}/reviews:
  post:
    tags: [Reviews]
    security: [{ bearerAuth: [] }]
    summary: Buyer creates a review for a delivered order_item
    parameters:
      - { name: order_item, in: path, required: true, schema: { type: string, format: uuid } }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/NewReviewInput' }
    responses:
      '201':
        description: Review created
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/Review' }
      '401': { $ref: '#/components/responses/Unauthenticated' }
      '403': { $ref: '#/components/responses/Forbidden' }
      '422': { description: 'Validation failed OR order not delivered yet' }
      '409': { description: 'Review already exists for this order_item' }

/v1/reviews/{review}:
  patch:
    tags: [Reviews]
    security: [{ bearerAuth: [] }]
    summary: Buyer edits own review within the 30-day window
    parameters:
      - { name: review, in: path, required: true, schema: { type: string, format: uuid } }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/EditReviewInput' }
    responses:
      '200':
        description: Review updated
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/Review' }
      '401': { $ref: '#/components/responses/Unauthenticated' }
      '403': { $ref: '#/components/responses/Forbidden' }
      '422': { description: 'Validation failed OR edit window expired' }

/v1/order-items/{order_item}/review-attachments:
  post:
    tags: [Reviews]
    security: [{ bearerAuth: [] }]
    summary: Buyer stages a single image upload for a future review (dedicated upload endpoint, parallel to Layer 9's messages/attachments)
    parameters:
      - { name: order_item, in: path, required: true, schema: { type: string, format: uuid } }
    requestBody:
      required: true
      content:
        multipart/form-data:
          schema:
            type: object
            required: [file]
            properties:
              file:
                type: string
                format: binary
    responses:
      '201':
        description: Staged upload created
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ReviewAttachmentUpload' }
      '401': { $ref: '#/components/responses/Unauthenticated' }
      '403': { description: 'Caller is not the buyer-of-record for this order_item' }
      '422': { description: 'Validation failed OR order not yet delivered' }

/v1/me/reviews:
  get:
    tags: [Reviews]
    security: [{ bearerAuth: [] }]
    summary: List the authenticated buyer's reviews
    responses:
      '200':
        description: Paginated list of own reviews
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/PaginatedResponse'
                - type: object
                  properties:
                    data: { type: array, items: { $ref: '#/components/schemas/Review' } }
      '401': { $ref: '#/components/responses/Unauthenticated' }

/v1/stores/{store}/reviews:
  get:
    tags: [Reviews]
    summary: Public list of visible reviews for a store (no auth required)
    parameters:
      - { name: store, in: path, required: true, schema: { type: string, format: uuid } }
    responses:
      '200':
        description: Paginated list of visible reviews
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/PaginatedResponse'
                - type: object
                  properties:
                    data: { type: array, items: { $ref: '#/components/schemas/Review' } }

/v1/stores/{store}/rating-summary:
  get:
    tags: [Reviews]
    summary: Public lightweight rating badge data for a store (no auth required)
    parameters:
      - { name: store, in: path, required: true, schema: { type: string, format: uuid } }
    responses:
      '200':
        description: Aggregated rating summary
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/RatingSummary' }
```

Schemas:

```yaml
Review:
  type: object
  required: [id, store_id, rating, dimensions, body, state, created_at]
  properties:
    id: { type: string, format: uuid }
    order_item_id: { type: string, format: uuid }
    order_id: { type: string, format: uuid }
    store_id: { type: string, format: uuid }
    reviewer:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
    rating: { type: integer, minimum: 1, maximum: 5 }
    dimensions:
      type: object
      properties:
        item_as_described: { type: integer, minimum: 1, maximum: 5 }
        shipping_speed:    { type: integer, minimum: 1, maximum: 5 }
        communication:     { type: integer, minimum: 1, maximum: 5 }
        packaging:         { type: integer, minimum: 1, maximum: 5 }
    title: { type: string, nullable: true, maxLength: 120 }
    body:  { type: string, minLength: 20, maxLength: 2000 }
    state: { type: string, enum: [visible] }
    photos:
      type: array
      items:
        type: object
        properties:
          id: { type: string }
          url: { type: string, format: uri }
          content_type: { type: string }
    created_at: { type: string, format: date-time }
    updated_at: { type: string, format: date-time }
    edited_at:  { type: string, format: date-time, nullable: true }
    is_within_edit_window: { type: boolean }

NewReviewInput:
  type: object
  required: [rating, body]
  properties:
    rating: { type: integer, minimum: 1, maximum: 5 }
    rating_item_as_described: { type: integer, minimum: 1, maximum: 5, nullable: true }
    rating_shipping_speed:    { type: integer, minimum: 1, maximum: 5, nullable: true }
    rating_communication:     { type: integer, minimum: 1, maximum: 5, nullable: true }
    rating_packaging:         { type: integer, minimum: 1, maximum: 5, nullable: true }
    title: { type: string, maxLength: 120, nullable: true }
    body:  { type: string, minLength: 20, maxLength: 2000 }
    attachment_ids:
      type: array
      maxItems: 4
      items: { type: string, format: uuid }

EditReviewInput:
  # Same shape as NewReviewInput. Edits replace the editable fields entirely.
  type: object
  required: [rating, body]
  properties:
    rating: { type: integer, minimum: 1, maximum: 5 }
    rating_item_as_described: { type: integer, minimum: 1, maximum: 5, nullable: true }
    rating_shipping_speed:    { type: integer, minimum: 1, maximum: 5, nullable: true }
    rating_communication:     { type: integer, minimum: 1, maximum: 5, nullable: true }
    rating_packaging:         { type: integer, minimum: 1, maximum: 5, nullable: true }
    title: { type: string, maxLength: 120, nullable: true }
    body:  { type: string, minLength: 20, maxLength: 2000 }
    attachment_ids:
      type: array
      maxItems: 4
      items: { type: string, format: uuid }

ReviewAttachmentUpload:
  type: object
  required: [id, url, thumb_url, content_type, size_bytes]
  properties:
    id:           { type: string, format: uuid }
    url:          { type: string, format: uri }
    thumb_url:    { type: string, format: uri }
    content_type: { type: string }
    size_bytes:   { type: integer, minimum: 0 }

RatingSummary:
  type: object
  required: [store_id, average_rating, review_count, distribution, dimensions]
  properties:
    store_id: { type: string, format: uuid }
    average_rating: { type: number, format: float, nullable: true }
    review_count:   { type: integer, minimum: 0 }
    distribution:
      type: object
      properties:
        '1': { type: integer, minimum: 0 }
        '2': { type: integer, minimum: 0 }
        '3': { type: integer, minimum: 0 }
        '4': { type: integer, minimum: 0 }
        '5': { type: integer, minimum: 0 }
    dimensions:
      type: object
      properties:
        item_as_described: { type: number, format: float, nullable: true }
        shipping_speed:    { type: number, format: float, nullable: true }
        communication:     { type: number, format: float, nullable: true }
        packaging:         { type: number, format: float, nullable: true }
```

Validate: `python3 -c "import yaml; yaml.safe_load(open('api/contracts/openapi.yaml'))"`.

- [ ] **Step 1: Edit the YAML.**
- [ ] **Step 2: Validate parse.**

---

### Task 13: Sync to web + types + api-client

**Files:**
- Sync: `~/projects/alqove-web/contracts/openapi.yaml` (via `./bin/sync-openapi.sh`)
- Build: `npm run build:types` (regenerates `packages/types/src/generated.ts`)
- Create: `web/packages/api-client/src/endpoints/reviews.ts`
- Update: `web/packages/api-client/src/index.ts` (export the new endpoints)

```ts
import type { AlqoveClient } from '../client';
import type { ApiResponse, PaginatedResponse } from '../types';

export interface Review {
  id: string;
  order_item_id: string;
  order_id: string;
  store_id: string;
  reviewer: { id: string; name: string | null };
  rating: number;
  dimensions: {
    item_as_described: number;
    shipping_speed: number;
    communication: number;
    packaging: number;
  };
  title: string | null;
  body: string;
  state: 'visible';
  photos: Array<{ id: string; url: string; content_type: string }>;
  created_at: string;
  updated_at: string;
  edited_at: string | null;
  is_within_edit_window: boolean;
}

export interface RatingSummary {
  store_id: string;
  average_rating: number | null;
  review_count: number;
  distribution: { 1: number; 2: number; 3: number; 4: number; 5: number };
  dimensions: {
    item_as_described: number | null;
    shipping_speed: number | null;
    communication: number | null;
    packaging: number | null;
  };
}

export interface NewReviewInput {
  rating: number;
  rating_item_as_described?: number | null;
  rating_shipping_speed?: number | null;
  rating_communication?: number | null;
  rating_packaging?: number | null;
  title?: string | null;
  body: string;
  attachment_ids?: string[];
}

export type EditReviewInput = NewReviewInput;

export interface ReviewAttachmentUploadResponse {
  id: string;
  url: string;
  thumb_url: string;
  content_type: string;
  size_bytes: number;
}

export function createReviewEndpoints(client: AlqoveClient) {
  return {
    create(orderItemId: string, input: NewReviewInput) {
      return client.post<ApiResponse<Review>>(`/v1/order-items/${orderItemId}/reviews`, input);
    },
    update(reviewId: string, input: EditReviewInput) {
      return client.patch<ApiResponse<Review>>(`/v1/reviews/${reviewId}`, input);
    },
    uploadAttachment(orderItemId: string, file: File) {
      const fd = new FormData();
      fd.append('file', file);
      return client.post<ApiResponse<ReviewAttachmentUploadResponse>>(
        `/v1/order-items/${orderItemId}/review-attachments`,
        fd,
      );
    },
    myReviews(page = 1) {
      return client.get<PaginatedResponse<Review>>(`/v1/me/reviews?page=${page}`);
    },
    forStore(storeId: string, page = 1) {
      return client.get<PaginatedResponse<Review>>(`/v1/stores/${storeId}/reviews?page=${page}`);
    },
    ratingSummary(storeId: string) {
      return client.get<ApiResponse<RatingSummary>>(`/v1/stores/${storeId}/rating-summary`);
    },
  };
}
```

- [ ] **Step 1: Sync.**
- [ ] **Step 2: Build types.**
- [ ] **Step 3: Wire endpoints + re-export.**
- [ ] **Step 4: Typecheck** — `npm run typecheck` at root + `npx tsc --noEmit` in `web/`. Clean.

---

## Phase F — Frontend

### Task 14: TanStack hooks

**Files:**
- Create: `web/src/lib/queries/use-reviews.ts` (all five hooks colocated; matches `use-purchases.ts` / `use-returns.ts` precedent)
- Test: `web/src/lib/queries/__tests__/use-reviews.test.ts`

```ts
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { NewReviewInput, EditReviewInput, Review, RatingSummary } from '@alqove/api-client';

export const REVIEW_KEYS = {
  myReviews: (page: number) => ['me', 'reviews', page] as const,
  forStore: (storeId: string, page: number) => ['stores', storeId, 'reviews', page] as const,
  summary: (storeId: string) => ['stores', storeId, 'rating-summary'] as const,
};

export function useMyReviews(page = 1) {
  return useQuery({
    queryKey: REVIEW_KEYS.myReviews(page),
    queryFn: () => api.reviews.myReviews(page),
  });
}

export function useReviewsForStore(storeId: string, page = 1) {
  return useQuery({
    queryKey: REVIEW_KEYS.forStore(storeId, page),
    queryFn: () => api.reviews.forStore(storeId, page),
    enabled: Boolean(storeId),
  });
}

export function useRatingSummary(storeId: string | null | undefined) {
  return useQuery({
    queryKey: REVIEW_KEYS.summary(storeId ?? 'none'),
    queryFn: () => api.reviews.ratingSummary(storeId!),
    enabled: Boolean(storeId),
    staleTime: 60_000,
  });
}

export function useCreateReview() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ orderItemId, input }: { orderItemId: string; input: NewReviewInput }) =>
      api.reviews.create(orderItemId, input),
    onSuccess: (res, { input }) => {
      qc.invalidateQueries({ queryKey: ['me', 'reviews'] });
      qc.invalidateQueries({ queryKey: ['stores', res.data.store_id] });
      qc.invalidateQueries({ queryKey: ['purchases'] });  // CTA visibility on purchases page
    },
  });
}

export function useUpdateReview() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ reviewId, input }: { reviewId: string; input: EditReviewInput }) =>
      api.reviews.update(reviewId, input),
    onSuccess: (res) => {
      qc.invalidateQueries({ queryKey: ['me', 'reviews'] });
      qc.invalidateQueries({ queryKey: ['stores', res.data.store_id] });
    },
  });
}
```

- [ ] **Step 1: Write smoke tests** (4–6 cases: each hook calls the right endpoint; mutations invalidate the right keys).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to PASS.**

---

### Task 15: `<ReviewModal>` component (the buyer's create/edit surface)

**Files:**
- Create: `web/src/components/reviews/review-modal.tsx`
- Create: `web/src/components/reviews/star-picker.tsx` *(reusable 5-star picker — receives `value`, `onChange`)*
- Create: `web/src/components/reviews/review-photo-uploader.tsx` *(uses the new dedicated `POST /v1/order-items/{order_item}/review-attachments` endpoint)*
- Test: `web/src/components/reviews/__tests__/review-modal.test.tsx`
- Test: `web/src/components/reviews/__tests__/star-picker.test.tsx`

`<StarPicker>` — controlled component, 5 buttons:

```tsx
'use client';

import { useState } from 'react';

interface Props {
  value: number;
  onChange: (v: number) => void;
  label?: string;
  size?: 'sm' | 'md' | 'lg';
}

export function StarPicker({ value, onChange, label, size = 'md' }: Props) {
  const [hover, setHover] = useState<number | null>(null);
  const display = hover ?? value;
  const px = size === 'sm' ? 'text-base' : size === 'lg' ? 'text-3xl' : 'text-xl';

  return (
    <div className="flex items-center gap-2">
      {label && <span className="text-sm text-slate-700 min-w-[140px]">{label}</span>}
      <div className="flex gap-0.5">
        {[1, 2, 3, 4, 5].map((n) => (
          <button
            key={n}
            type="button"
            onClick={() => onChange(n)}
            onMouseEnter={() => setHover(n)}
            onMouseLeave={() => setHover(null)}
            aria-label={`${n} star${n === 1 ? '' : 's'}`}
            className={`${px} leading-none transition-colors ${
              n <= display ? 'text-amber-500' : 'text-slate-300'
            }`}
          >
            ★
          </button>
        ))}
      </div>
      <span className="text-xs text-slate-500 ml-1">{value}/5</span>
    </div>
  );
}
```

`<ReviewModal>` — covers both create and edit modes:

```tsx
interface ReviewModalProps {
  mode: 'create' | 'edit';
  orderItemId?: string;          // required in create mode; also drives the photo-upload endpoint
  existing?: Review | null;      // required in edit mode
  onClose: () => void;
  onSubmit: () => void;          // called after successful mutation
}

export function ReviewModal({ mode, orderItemId, existing, onClose, onSubmit }: ReviewModalProps) {
  const [rating, setRating] = useState(existing?.rating ?? 5);
  const [itemAsDescribed, setItemAsDescribed] = useState(existing?.dimensions.item_as_described ?? 5);
  const [shippingSpeed, setShippingSpeed] = useState(existing?.dimensions.shipping_speed ?? 5);
  const [communication, setCommunication] = useState(existing?.dimensions.communication ?? 5);
  const [packaging, setPackaging] = useState(existing?.dimensions.packaging ?? 5);
  const [title, setTitle] = useState(existing?.title ?? '');
  const [body, setBody] = useState(existing?.body ?? '');
  const [attachmentIds, setAttachmentIds] = useState<string[]>([]);

  // Pre-fill dimension pickers from overall on overall-change:
  // when the buyer adjusts the overall rating, the four dimension pickers
  // initialize to the same value. The buyer can then move individual dimensions.
  // After the buyer touches any dimension picker explicitly, we stop pre-filling
  // that one (track "touched" set).
  const [touched, setTouched] = useState<Set<string>>(new Set());

  function onOverallChange(v: number) {
    setRating(v);
    if (!touched.has('item_as_described')) setItemAsDescribed(v);
    if (!touched.has('shipping_speed'))    setShippingSpeed(v);
    if (!touched.has('communication'))     setCommunication(v);
    if (!touched.has('packaging'))         setPackaging(v);
  }

  const create = useCreateReview();
  const update = useUpdateReview();

  const bodyTooShort = body.length < 20;
  const bodyTooLong = body.length > 2000;
  const titleTooLong = title.length > 120;
  const canSubmit = !bodyTooShort && !bodyTooLong && !titleTooLong;

  function submit() {
    const input = {
      rating,
      rating_item_as_described: itemAsDescribed,
      rating_shipping_speed: shippingSpeed,
      rating_communication: communication,
      rating_packaging: packaging,
      title: title.trim() || null,
      body,
      attachment_ids: attachmentIds,
    };

    if (mode === 'create') {
      create.mutate({ orderItemId: orderItemId!, input }, { onSuccess: onSubmit });
    } else {
      update.mutate({ reviewId: existing!.id, input }, { onSuccess: onSubmit });
    }
  }

  // Render: modal shell + StarPicker(overall) + 4 StarPickers(dimensions) + title input + body textarea (with char counter) + ReviewPhotoUploader + submit button
  // (full markup elided for plan brevity)
}
```

`<ReviewPhotoUploader>` uses the new dedicated `POST /v1/order-items/{order_item}/review-attachments` endpoint via the api-client wrapper `api.reviews.uploadAttachment(orderItemId, file)` (added in Task 13 alongside the other review endpoints):

```tsx
export function ReviewPhotoUploader({ orderItemId, attachmentIds, onChange }: Props) {
  // up to 4 files; each upload returns { id, url, thumb_url };
  // collect into attachmentIds. Pattern parallels the messaging composer's
  // existing attachment uploader but hits a dedicated, order-item-scoped
  // endpoint (Layer 12 Amendment 1).
}
```

> **Plan note (pre-fill dimension pickers from overall):** UX intent from the spec line 156: "5-star overall picker (required), 4 dimension pickers (optional — UI defaults each to the overall value but buyer can override)". The "touched" set tracks which dimensions the buyer has explicitly moved; only untouched dimensions follow the overall. This matches user expectation: rate 4 overall → all dimensions visually init to 4; if the buyer then drags one dimension to 2, changing overall to 5 only moves the other three (the deliberate 2 stays). The four explicit values are sent on submit; the default-to-overall posture on the server is a fallback for direct API consumers who omit fields entirely.

> **Plan note (parallel-implemented uploader; no shared hook in Plan 1):** Audit `web/src/components/messaging/` for the existing attachment uploader and mirror its UI/UX patterns (drag-drop affordance, per-file progress, error handling, cap-at-4 with disabled state on the 5th drop). Do **not** extract a shared `useStagedUpload(scopeId)` hook in Plan 1 — the two uploaders now hit different endpoints with different URL templates (`/orders/{order}/messages/attachments` vs `/order-items/{order_item}/review-attachments`), so the polymorphism would add more ceremony than it saves. A future hook could parameterize the endpoint + scope-id, but Plan 1 keeps two thin parallel components for clarity and matches the backend's parallel-controllers posture.

- [ ] **Step 1: Write the failing tests:**

```ts
// star-picker.test.tsx
test('renders 5 buttons, highlights N stars for value=N');
test('onChange fires with star number on click');
test('hover state highlights up to hovered star');

// review-modal.test.tsx
test('create mode pre-fills 5 stars overall and all dimensions');
test('changing overall updates untouched dimensions');
test('touching a dimension freezes it; subsequent overall changes do not move it');
test('body shorter than 20 chars disables submit');
test('body longer than 2000 chars disables submit');
test('title longer than 120 chars disables submit');
test('submit fires create mutation with full payload in create mode');
test('submit fires update mutation in edit mode');
test('cancel closes the modal without firing mutation');
test('edit mode pre-fills from existing review');
```

- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement** the three components.
- [ ] **Step 4: Run; iterate to ~13/13 PASS.**

---

### Task 16: "Leave a review" CTA on `/purchases/{id}`

**Files:**
- Update: `web/src/app/(buyer)/purchases/[id]/purchase-detail-client.tsx` (extend `OrderCard` with per-`order_item` review CTAs)
- Update: `web/src/app/(buyer)/purchases/[id]/__tests__/purchase-detail-client.test.tsx`
- Maybe-update: `web/packages/api-client/src/endpoints/purchases.ts` if the existing purchase-detail payload doesn't yet surface `existing_review_id` on each `OrderItemData`

> **Plan note (where to source per-`order_item` review state):** Two design choices:
> - **(a) Embed `existing_review_id` on each `OrderItemData` in the purchase-detail response** — server-side join, one query, simplest UI. Requires a small change to `PurchaseDetailResource` to include `existing_review_id` (and ideally `existing_review_within_edit_window`) per item.
> - **(b) Client-side fetch of `/me/reviews` and dictionary-lookup.** Two queries, more state. Doesn't scale if buyers have many purchases.
> **Plan 1 picks (a).** Update `OrderItemResource` (or `PurchaseDetailResource` wherever the per-item shape is defined) to add `existing_review_id: ?string` and `existing_review_within_edit_window: bool`. Update OpenAPI's `OrderItemData` shape correspondingly. Add API tests for the new fields in `tests/Feature/Orders/` or wherever purchase-detail is tested.

In the `OrderCard`:

```tsx
{order.delivered_at && order.items.map((item) => (
  <ReviewCtaForItem
    key={item.id}
    orderItemId={item.id}
    existingReviewId={item.existing_review_id}
    existingReviewWithinWindow={item.existing_review_within_edit_window}
  />
))}
```

`<ReviewCtaForItem>`:
- No existing review → "Leave a review" button → opens `<ReviewModal mode='create' orderItemId>` (the modal scopes the photo uploader by `orderItemId` against the new `/v1/order-items/{order_item}/review-attachments` endpoint).
- Existing review + within window → "View / edit your review" link → opens `<ReviewModal mode='edit' existing={fetchedReview}>`.
- Existing review + outside window → "View your review" link → opens read-only modal or navigates to `/me/reviews`.

- [ ] **Step 1: Write tests** asserting CTA visibility per state (4–6 cases).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Update the purchase-detail resource + OpenAPI; sync types; implement the CTA component.**
- [ ] **Step 4: Run; iterate to PASS.**

---

### Task 17: `/me/reviews` page

**Files:**
- Create: `web/src/app/(buyer)/me/reviews/page.tsx`
- Create: `web/src/app/(buyer)/me/reviews/me-reviews-client.tsx`
- Test: `web/src/app/(buyer)/me/reviews/__tests__/me-reviews-client.test.tsx`

Chronological list of own reviews; for each, show store name + rating stars + title + body excerpt + photos + edited marker + "Edit" button gated on `is_within_edit_window`.

- [ ] **Step 1: Write tests** (5 cases: empty state, paginated list, edit button visibility within window, edit button absence after window, photo rendering).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 5/5 PASS.**

---

### Task 18: Item-detail "Sold by [Store] ★ 4.8 (142 reviews)" badge

**Files:**
- Update: `web/src/app/(buyer)/items/[id]/item-detail-client.tsx` *(or wherever the seller-info section lives)*
- Update: `web/src/app/(buyer)/items/[id]/__tests__/...`

```tsx
const { data: summary } = useRatingSummary(item.store?.id);

{summary?.data && summary.data.review_count > 0 && (
  <Link href={`/stores/${item.store!.id}#reviews`} className="text-sm hover:underline">
    Sold by <span className="font-semibold">{item.store!.name}</span>{' '}
    <span className="text-amber-500">★</span>{' '}
    <span className="font-medium">{summary.data.average_rating?.toFixed(1)}</span>{' '}
    <span className="text-slate-500">({summary.data.review_count} review{summary.data.review_count === 1 ? '' : 's'})</span>
  </Link>
)}
{summary?.data && summary.data.review_count === 0 && (
  <span className="text-sm text-slate-500">Sold by <span className="font-semibold">{item.store!.name}</span></span>
)}
```

- [ ] **Step 1: Write tests** (3 cases: badge renders with rating, badge hidden when count is 0, click navigates to store reviews anchor).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 3/3 PASS.**

---

### Task 19: Store-detail page extensions

**Files:**
- Create: `web/src/components/reviews/rating-badge.tsx` *(top-of-page large badge: ★ 4.8 with count and click-jump-to-reviews)*
- Create: `web/src/components/reviews/dimension-breakdown-card.tsx` *(4 horizontal bars with dimension means)*
- Create: `web/src/components/reviews/reviews-list.tsx` *(paginated list with photos + edited markers)*
- Update: `web/src/app/(buyer)/stores/[id]/store-detail-client.tsx` (add rating badge near header; reviews section below product grid)
- Update: `web/src/app/(buyer)/stores/[id]/page.tsx` if it needs to pre-fetch summary for SSR
- Test: `web/src/components/reviews/__tests__/rating-badge.test.tsx`
- Test: `web/src/components/reviews/__tests__/dimension-breakdown-card.test.tsx`
- Test: `web/src/components/reviews/__tests__/reviews-list.test.tsx`
- Update: `web/src/app/(buyer)/stores/[id]/__tests__/store-detail-client.test.tsx`

> **Plan note (no "Report" button in Plan 1):** Spec lists report on each public review; that lands in Plan 2. The `<ReviewsList>` component leaves a comment placeholder where the Report button will be wired so Plan 2's diff is small.

- [ ] **Step 1: Write tests** for each component (3–4 cases each).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to PASS.**

---

### Task 20: Seller dashboard widget + `/seller/reviews` page

**Files:**
- Create: `web/src/components/seller/seller-reviews-widget.tsx` *(small widget for `/seller` home: overall rating + count + dimension means in tight grid)*
- Update: `web/src/app/(seller)/seller/page.tsx` (mount widget above the KPI row or alongside the balance widget)
- Create: `web/src/app/(seller)/seller/reviews/page.tsx`
- Create: `web/src/app/(seller)/seller/reviews/seller-reviews-client.tsx`
- Test: `web/src/components/seller/__tests__/seller-reviews-widget.test.tsx`
- Test: `web/src/app/(seller)/seller/reviews/__tests__/seller-reviews-client.test.tsx`

`/seller/reviews` is read-only — chronological list (newest first), paginated, showing rating + title + body + photos + buyer name (truncated to first name + last-initial for privacy: `John D.`). No filter pills in Plan 1 (all reviews are `visible`; Plan 2 adds the filter once `hidden` exists). Mirrors `/seller/payouts` page layout (`web/src/app/(seller)/seller/payouts/seller-payouts-client.tsx`) — paginated table, no destructive UI.

> **Plan note (no Plan 2 filter pills in Plan 1):** The widget and page render `state === 'visible'` reviews only — that's also what the server returns from `/v1/seller/reviews`-equivalent calls (Plan 1 reuses `/v1/me/reviews`? **No** — `/me/reviews` is the buyer surface). **`/seller/reviews` needs a new endpoint** `GET /v1/seller/reviews` (under `auth:sanctum` + `store.owner` middleware) that returns the seller's store's reviews paginated. **Add this in Task 8** (extend `PublicReviewController` or add a new `SellerReviewController`) **or fold into a dedicated endpoint.**

> **Plan note (`/v1/seller/reviews` endpoint addition) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The original task brief listed only five endpoints — POST create, PATCH edit, GET me, GET store public, GET rating-summary. The seller-side `/seller/reviews` page needs a sixth endpoint: `GET /v1/seller/reviews` — auth+store.owner, paginated, all reviews on the seller's own store (same as the public store-reviews endpoint but using the authed user's store_id). **Plan 1 adds this endpoint** because otherwise the seller page would have to call the public endpoint with the seller's own store id, which works but is ugly and conflates audiences. Files: extend `api/app/Modules/Reviews/Controllers/ReviewController.php` with a `sellerReviews(Request)` method (auth user → store_id → query), add a route in `api/app/Modules/Reviews/routes.php`, add OpenAPI + types + api-client wrapper. Roll the work into Task 8 or Task 13 depending on where it fits cleaner; an explicit checklist line in Task 21 calls it out.

- [ ] **Step 1: Write tests** (4–6 cases: widget renders aggregate, page renders paginated list, empty state, photo rendering).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to PASS.**

---

### Task 21: Wire `/v1/seller/reviews` endpoint (the late-bound seller-scoped read endpoint)

**Files:**
- Update: `api/app/Modules/Reviews/Controllers/ReviewController.php` (add `sellerReviews`)
- Update: `api/app/Modules/Reviews/routes.php` (add the route inside an `auth:sanctum` + `store.owner` middleware block)
- Update: `api/contracts/openapi.yaml` (add the path)
- Update: `web/packages/api-client/src/endpoints/reviews.ts` (add `sellerReviews(storeId, page)`)
- Test: `api/tests/Feature/Reviews/SellerReviewsEndpointTest.php`

```php
public function sellerReviews(Request $request): AnonymousResourceCollection
{
    $storeId = $request->user()->store_id;
    abort_if($storeId === null, 422, 'No store associated with this account.');

    $reviews = Review::query()
        ->where('store_id', $storeId)
        ->with(['reviewer', 'media'])
        ->orderByDesc('created_at')
        ->paginate(20);

    return ReviewResource::collection($reviews);
}
```

Route inside the seller-owner-middleware block (note: `/seller/reviews` is path-keyed off the auth user, not a path id, so no `store.owner` middleware is technically necessary; auth alone suffices since the controller derives store_id from the user):

```php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/seller/reviews', [ReviewController::class, 'sellerReviews']);
    // ... (other authed routes from Task 5)
});
```

- [ ] **Step 1: Write the test** (5 cases: returns own store's reviews, 422 when user has no store, paginates, excludes other stores' reviews, requires auth).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 5/5 PASS.**

---

## Phase G — Wrap-up

### Task 22: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: **1021 → ~1094** (~73 new tests across all phases — 10 schema + 5 store-columns + 6 review-attachment-uploads schema + ~2 stores-rating-cleanup + 12 eligibility + 19 writer + 18 create endpoint + 10 upload-attachment endpoint + 10 update endpoint + 7 me endpoint + 13 public endpoints + 4 published-notification + 4 received-notification + ~5 seller-reviews + minor double-counting where existing tests were extended). The amended target is **+72–74**; +73 lands in range. (Original target was +64; Amendment 1 adds ~8 for the upload-attachment endpoint + schema; Amendment 2 adds ~1–2 for the cleanup negative assertion.)

- [ ] **Step 2: Backend lint** — `cd api && ./vendor/bin/pint app/Modules/Reviews app/Modules/Notifications/Notifications/ReviewPublishedNotification.php app/Modules/Notifications/Notifications/ReviewReceivedNotification.php app/Models/Review.php app/Models/ReviewAttachmentUpload.php app/Models/Store.php app/Modules/Stores/Resources/StorePublicResource.php app/Modules/Admin/Resources/AdminStoreResource.php app/Modules/Admin/Resources/AdminStoreDetail.php app/Support/Enums/NotificationCategory.php database/migrations/2026_05_13_100001_create_reviews_table.php database/migrations/2026_05_13_100002_add_review_counters_to_stores_table.php database/migrations/2026_05_13_100003_create_review_attachment_uploads_table.php database/migrations/2026_05_13_100099_drop_legacy_rating_from_stores.php database/factories/ReviewFactory.php database/factories/ReviewAttachmentUploadFactory.php database/factories/StoreFactory.php tests/Feature/Reviews tests/Feature/Notifications`. Expected PASS or auto-fix.

- [ ] **Step 3: Web typecheck** — `npm run typecheck` at root + `npx tsc --noEmit` in `web/`. Expected clean.

- [ ] **Step 4: Web lint** — `npm run lint`. Expected baseline (no new warnings).

- [ ] **Step 5: Web tests** — `npm run test`. Expected: **308 → ~330** (~22 new — hooks smoke ~5 + star-picker ~3 + review-modal ~10 + me-reviews-page ~5 + rating-badge ~3 + dimension-breakdown ~3 + reviews-list ~3 + seller-widget ~3 + seller-reviews-page ~4 + purchase-detail-client extensions ~4 + item-detail badge ~3, with some overlap; target was +20).

- [ ] **Step 6: Local web build** — `npm run build:web`. Should be a clean static build; all new components are client-side via `'use client'`.

- [ ] **Step 7: Brief manual-QA scenarios** *(buyer write flow only — public read paths are covered by integration tests):*
  1. **Happy path create** — Log in as a buyer; navigate to `/purchases/{id}` of a delivered order; click "Leave a review" on an order_item; rate 5 stars overall (verify dimensions follow); type a 50-character body; submit; expect 201, modal closes, the CTA replaces with "View your review"; the store-detail page reflects the new aggregate; the seller sees a "New review on your shop" toast/notification.
  2. **Edit within window** — Click "View / edit your review"; change rating from 5 to 3; submit; expect 200 + `edited_at` populated on the public store page.
  3. **Body validation** — Try submitting a 10-character body; expect inline validation error; client-side submit button disabled.
  4. **Photo upload** — Attach 2 photos via the uploader; submit; expect photos to render on the public store review.
  5. **Eligibility rejection** — Manually craft a request to POST `/v1/order-items/{some-other-buyer's-item}/reviews` with the current bearer token; expect 403.

### Task 23: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app config contracts database tests docs` and commit with `feat(reviews): foundation — schema, write services, public read endpoints, notifications`.

- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit with `feat(reviews): buyer write flow, public store rating + reviews list, seller reviews page`.

- [ ] **Step 3:** Push both. Watch GH Actions on each — both should be green inside ~3 minutes (no new CI env vars needed; reviews are purely internal).

---

## Open items deferred to Plan 2 (or later)

- **`review_reports` table + model + factory.** Plan 2.
- **`POST /v1/reviews/{review}/reports` endpoint** (any logged-in user can report). Plan 2.
- **Admin moderation queue** (`GET /v1/admin/review-reports`, `POST /v1/admin/review-reports/{report}/resolve`, `POST /v1/admin/reviews/{review}/restore`). Plan 2.
- **The `hidden` state value on `reviews.state`** + the `hidden_by_admin_id` / `hidden_at` / `hide_reason` columns. Plan 2 widens the CHECK constraint and adds the columns.
- **`ReviewReportedNotification` (admin) and `ReviewHiddenNotification` (buyer).** Plan 2.
- **Activity-log entries** (`review.report_resolved`, `review.admin_hidden`, `review.admin_restored`). Plan 2.
- **Filter pills on `/seller/reviews`** for `visible` vs `hidden`. Plan 2 (meaningless until `hidden` exists).
- **Admin store-detail Reviews tab** with per-review hide/restore. Plan 2.
- **Typesense field push** (`store.average_rating`, `store.review_count`, four dimensions) onto item index rows + Scout sync hooks on Store aggregate change. Plan 3.
- **Listing-card inline "★ 4.8 (142)" badge** powered by the new Typesense fields. Plan 3.
- **`review.buyer_edited` activity-log row** with diff in properties for self-edits. Plan 3 (low priority but useful for fraud investigation).
- **Photo removal on edit.** Plan 1 allows append-only edits to photos. If a buyer needs to remove a photo, no UI in Plan 1; Plan 3 may add it.
- **Total-photo cap enforcement server-side** (Plan 1 caps per-batch but not total — buyer can theoretically build past 4 by editing repeatedly with 4 each time). Plan 3.
- **De-identification of reviewer names after account deletion** ("Buyer" / "Former buyer"). Spec line 197. Out of Plan 1 scope; relies on the account-deletion flow (Layer 13+?).
- **Refund-after-review and return-after-review interactions.** Spec lines 194–195 commit to "review stays". No code change in Plan 1 — reviews persist regardless; tests covering these scenarios are nice-to-have but optional.
- **Localization of notification copy + UI strings.** No translation pipeline yet; descriptions and notification subjects are plain English. Future cleanup, not Plan 1's concern.
- ~~**Cross-domain reuse of `MessageAttachmentUpload`** vs renaming to `OrderScopedUpload` + generic `/v1/orders/{order}/uploads` endpoint.~~ **RESOLVED in Plan 1 (Amendment 1)** — Plan 1 ships a dedicated `ReviewAttachmentUpload` model + `review_attachment_uploads` table + `POST /v1/order-items/{order_item}/review-attachments` endpoint (see Tasks 2b and 5b). No cross-domain coupling, no Plan-3 rename needed; messaging keeps its messaging-named surface.
- **Seller reply to reviews.** Layer 13 candidate.
- **Helpful / unhelpful votes.** Layer 13+ if it surfaces as a need.
- **Search-ranking weights that USE the new fields.** Layer 12 surfaces them via Plan 3; a successor layer wires them into ranking.
