# Layer 12 Plan 2: Reports + Moderation

> **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:** Wire up the public report → admin moderation loop on top of Plan 1's review foundation. Plan 2 ships the `review_reports` table + model + factory, extends the `ReviewState` enum with `Hidden` (Plan 1 shipped `Visible` only), relaxes the Postgres CHECK constraint on `reviews.state` so the new value writes, builds a `ReviewReportService` with four methods (`open`, `resolve`, `restore`, `adminHide`) that wraps every state-mutating step in a single transaction + reuses Plan 1's `ReviewWriter` aggregate recompute path, five HTTP endpoints (`POST /v1/reviews/{review}/reports`, `GET /v1/admin/review-reports`, `POST /v1/admin/review-reports/{report}/resolve`, `POST /v1/admin/reviews/{review}/restore`, `POST /v1/admin/reviews/{review}/hide`), two queueable notifications (`ReviewReportedNotification` for admin fanout, `ReviewHiddenNotification` for the buyer), three `spatie/laravel-activitylog` log lines (`review.report_resolved`, `review.admin_hidden`, `review.admin_restored`), and the corresponding OpenAPI + types + api-client wrappers plus the frontend: a "Report" button on every public review with a reason-radio dialog, an admin `/admin/reviews` queue page with state filter pills + a resolve dialog (action radio + resolution_note ≥ 10 chars), and an extension to the existing admin store-detail page that adds a Reviews tab with state filter pills + per-row Hide and Restore buttons (each opens a justification dialog with a required reason ≥ 10 chars; Hide hits a new direct-hide endpoint that mirrors `restore`). The store aggregate excludes hidden reviews automatically (Plan 1 already filters by `state = 'visible'` in `ReviewWriter::recomputeAggregates` — verified by reading the source); flipping a Review to `Hidden` or back to `Visible` re-runs the recompute under the same row lock. Sellers see only visible reviews on `/seller/reviews` (no change — Plan 1's `SellerReviewsController` already filters by `state = visible`). Buyers see their own hidden reviews on `/me/reviews` with a "Hidden by admin" marker plus the resolution note, so they understand the outcome and can appeal via support. **Acceptance:** any logged-in user reports a public review with a reason; admin sees the row in the moderation queue with the review content + reporter + reason inline; resolving with `hide` flips the review to `Hidden`, decrements the seller's `review_count`, recomputes all six denormalized aggregates excluding the hidden row, fires `ReviewHiddenNotification` to the buyer, writes a `review.admin_hidden` + a `review.report_resolved` activity-log row, and auto-resolves any other open reports against the same review with the same action + a synthesized resolution note; resolving with `keep` resolves the report (and any sibling open reports) without touching the review; restoring a hidden review flips it back to `Visible`, recomputes aggregates again, and writes a `review.admin_restored` activity-log row; admin can ALSO hide a visible review directly from the store-detail Reviews tab via a new dedicated endpoint that mirrors restore (admin-only + required reason ≥ 10 chars), fires the same `ReviewHiddenNotification` to the buyer + writes the same `review.admin_hidden` activity log, but leaves any open reports against that review unchanged in the queue. No bulk-hide endpoint (Plan 3 owns that); no seller-reply tooling (deferred beyond Layer 12).

**Architecture:** (1) **Schema** — one migration creates the `review_reports` table (UUID PK; `review_id` FK; `reported_by_user_id` FK; `reason` string enum `inappropriate | spam | not_about_purchase | personal_info | other`; `reason_text` text nullable; `state` string enum `open | resolved` default `open`; `resolved_by_admin_id` FK nullable; `resolved_at` timestamp nullable; `action` string enum `keep | hide` nullable; `resolution_note` text nullable; timestamps; index on `(state, created_at)` for the admin queue + index on `(review_id, state)` for the per-review duplicate-report lookup). A second migration extends the Postgres CHECK constraint on `reviews.state` from `IN ('visible')` to `IN ('visible', 'hidden')` (gated on `pgsql`, drop-then-recreate). **No new columns on `reviews`** — Plan 1 already added `hidden_by_admin_id`, `hidden_at`, `hide_reason` nullable columns in anticipation of Plan 2 (verified by reading `2026_05_13_100001_create_reviews_table.php` lines 31–33 + `Review::$fillable` lines 60–63). The `ReviewState` enum at `api/app/Support/Enums/ReviewState.php` already exists with a single case (`Visible`); Plan 2 adds `case Hidden = 'hidden';`. Two new enums land: `ReportReason` (5 cases) + `ReviewReportState` (`open | resolved`) + `ReviewAdminAction` (`keep | hide`). The action enum is intentionally distinct from `App\Support\Enums\ReturnAdminAction` (which is `force_refund | force_close_no_refund | no_action`) — different domain, different cases, separate file. (2) **Service** — one `ReviewReportService` (final class, constructor-injected dependencies: `ReviewWriter` for the aggregate recompute reuse + `AdminRecipients` for the admin fanout). Four methods: `open(Review, User $reporter, ReportReason, ?string $reasonText): ReviewReport`, `resolve(ReviewReport, User $admin, ReviewAdminAction, string $resolutionNote): ReviewReport`, `restore(Review, User $admin, string $reason): Review`, `adminHide(Review, User $admin, string $reason): Review` (direct-hide bypassing the report queue; shares a private `applyHide` helper with the resolve `hide` branch so the state flip + aggregate recompute + buyer notification + activity-log emission are identical regardless of entry point). Every method wraps its work in `DB::transaction(function () { ... })`. `resolve` and `restore` re-use Plan 1's aggregate recompute: per the **architectural decision below**, Plan 2 widens `ReviewWriter::recomputeAggregates` from `private` to `public` (the method already exists at lines 169–182 of `api/app/Modules/Reviews/Services/ReviewWriter.php`; the class is `final` so no override risk; the method's signature `recomputeAggregates(Store $store): void` is already correct for direct invocation). The auto-resolve-siblings path inside `resolve` issues a `ReviewReport::query()->where('review_id', $report->review_id)->where('state', 'open')->where('id', '!=', $report->id)->get()` then updates each with the same admin/action + a synthesized `resolution_note` of `"Auto-resolved alongside report {primary_report_id} (action: {action})"`. Notifications fire post-commit via Laravel's queue (notifications are `ShouldQueue`); activity-log writes go inside the transaction so a rolled-back resolve doesn't leave orphan log rows. (3) **Endpoints** — five new endpoints. `POST /v1/reviews/{review}/reports` lives in `api/app/Modules/Reviews/routes.php` inside the existing `auth:sanctum` group (a new controller `ReviewReportController::store`). The admin endpoints live in `api/app/Modules/Admin/routes.php` inside the existing `auth:sanctum + admin + /admin` group (controllers `AdminReviewReportController::index + resolve` and `AdminReviewController::restore + hide`, both under `api/app/Modules/Reviews/Controllers/`). Resource classes: `ReviewReportResource` (single + collection), `ReviewReportSummaryResource` (for the admin queue with the eager-loaded review + reporter trim). (4) **Notifications** — `ReviewReportedNotification` (recipient via `AdminRecipients->all()`, category `Reviews`, mail + database, subject "New review report", CTA `/admin/reviews?focus={report_id}`) and `ReviewHiddenNotification` (recipient is the review's `reviewer`, category `Reviews`, mail + database, subject "Your review has been hidden", body includes the admin's resolution note, CTA `/me/reviews`). Both implement `ShouldQueue`; both call `NotificationPreferenceGate->channelsFor($notifiable, NotificationCategory::Reviews, ['mail', 'database'])` for channel selection (matches Plan 1's `ReviewPublishedNotification` shape verbatim). (5) **Activity log** — three log lines using the existing `spatie/laravel-activitylog` facade with `log_name = 'admin'` (matches `DisputeAdjudicator`, `ReturnEscalationService::resolve`, etc.): `review.report_resolved`, `review.admin_hidden`, `review.admin_restored`. All three include `causedBy($admin)`, `performedOn($review)`, and `withProperties([...])` carrying ids + the action/note text. (6) **OpenAPI + types + api-client** — four new paths + four new schemas (`ReviewReport`, `ReviewReportSummary`, `ReportReason` (`enum`), `ReviewAdminAction` (`enum`)) added to `contracts/openapi.yaml`; types regenerate via `npm run build:types`; new endpoint wrappers added to `web/packages/api-client/src/endpoints/reviews.ts` (extending the existing `createReviewEndpoints` factory). (7) **Frontend** — six pieces: (a) a `<ReportReviewButton>` rendered inline on every public review on `/stores/{id}` and on the item-detail `/items/{id}` page review section (visible only when `auth.user !== null`; falls back to a "Log in to report" tooltip otherwise); (b) a `<ReportReviewDialog>` component with a reason radio (5 options) + an optional `reason_text` textarea (becomes required + visible when `reason === 'other'`) + a Submit button that POSTs `/v1/reviews/{review}/reports`; (c) a `/admin/reviews/page.tsx` route + `admin-reviews-client.tsx` queue component with state filter pills (Open default, Resolved, All), a paginated table showing reporter, reason, review-snippet, store, "Review" button; (d) a `<AdminResolveReviewDialog>` component (mirrors `<AdminResolveReturnDialog>`) that shows the full review content + the report's reason + reason_text, with an action radio (`keep | hide`) + a `resolution_note` textarea (min 10 chars) + Submit; (e) an extension to `web/src/app/(admin)/admin/stores/[id]/store-detail-client.tsx` adding a third tab `'reviews'` (alongside the existing `'overview'` + `'ledger'`) with state filter pills (Visible | Hidden | All) + a paginated list + per-row action buttons (Hide on `state === 'visible'` rows, Restore on `state === 'hidden'` rows) that open a small justification dialog (reusing `ConfirmWithJustificationDialog`); Hide POSTs to the new `/v1/admin/reviews/{review}/hide` endpoint, Restore POSTs to `/v1/admin/reviews/{review}/restore`; (f) an extension to `web/src/app/(buyer)/me/reviews/my-reviews-client.tsx` rendering a "Hidden by admin" marker + the `hide_reason` text on any review whose `state === 'hidden'`. The admin sidebar nav at `web/src/app/(admin)/layout.tsx` lines 11–19 gains a `{ href: '/admin/reviews', label: 'Reviews' }` entry between Returns and Balances.

**Tech Stack:** Laravel 12, PHPUnit class-based feature tests under `api/tests/Feature/Reviews/` (matches Plan 1 + the `tests/Feature/Returns/` layout), Postgres 17 in CI with SQLite fallback for some local runs (CHECK constraints gated on driver), `ramsey/uuid` via the existing `App\Support\Traits\HasUuid` trait, Spatie laravel-activitylog v4 (already wired — used by Layers 8/10/11), `Notification::send` with `ShouldQueue` notifications, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library, the existing `useAuthStore` (zustand) for the admin-shell gate.

**Spec:** `docs/superpowers/specs/2026-05-13-layer-12-reviews-design.md`. The "Data model — `review_reports` table" section (lines 110–126), "Plan 2 — Reports + Moderation" section (lines 273–295), "Authorization & audit" section (lines 206–214), and the "Review lifecycle walkthrough — Report → moderation" subsection (lines 168–179) are the load-bearing references for this plan. Re-read them before Phase A. The "Invariants & edge cases" section's bullet on duplicate reports (line 200) is the source for the auto-resolve-siblings behaviour.

**Prerequisites:**
- API head: `ad5d929` (Layer 12 Plan 1 fully shipped). **1170 tests passing, 4 skipped** (the 4 skipped are Postgres-only CHECK-constraint assertions that skip under SQLite).
- Web head: `5d55c1b` (Layer 12 Plan 1 fully shipped on web). **342 tests passing, 1 skipped.**
- Plan 1 is the immediate predecessor. Direct file references:
  - `api/app/Support/Enums/ReviewState.php` — single case `Visible = 'visible'`; Plan 2 adds `Hidden = 'hidden'`.
  - `api/app/Models/Review.php` — already declares the cast `'state' => ReviewState::class` (line 70) AND already includes `hidden_by_admin_id`, `hidden_at`, `hide_reason` in `$fillable` (lines 61–63) AND has `'hidden_at' => 'datetime'` in casts (line 76) AND has `hiddenBy(): BelongsTo` (lines 107–110). **Plan 2 does NOT touch the model** — it was authored anticipating Plan 2's needs.
  - `api/database/migrations/2026_05_13_100001_create_reviews_table.php` — the `reviews` table already has `hidden_by_admin_id` FK + `hidden_at` timestamp + `hide_reason` text columns nullable (lines 31–33). The Postgres CHECK constraint at line 48 reads `CHECK (state IN ('visible'))` — Plan 2's migration drops + recreates it as `IN ('visible', 'hidden')`. **No new columns on `reviews` needed.**
  - `api/app/Modules/Reviews/Services/ReviewWriter.php` — `recomputeAggregates(Store $store): void` exists at lines 169–182 and already filters `$store->reviews()->where('state', 'visible')->get()` (line 171). Currently `private`. Plan 2 widens this to `public` so `ReviewReportService::resolve` + `::restore` can invoke it after flipping a Review's state. The class is `final` so widening is safe (no subclass risk). **Architectural decision flagged below.**
  - `api/app/Modules/Reviews/Controllers/SellerReviewsController.php` lines 35–37 already filter `Review::query()->where('store_id', ...)->where('state', ReviewState::Visible)`. Plan 2 makes no change here — sellers continue to see only visible reviews on `/seller/reviews`, as designed (rationale: don't expose hidden reviews to sellers since it could enable harassment of reporters). Documented as Plan note.
  - `api/app/Modules/Reviews/Controllers/PublicStoreReviewsController.php` lines 25 + 39 already filter `state = visible` for both `index` and `summary`. No change needed.
  - `api/app/Modules/Reviews/Controllers/BuyerReviewController.php` — the `/me/reviews` endpoint at line 57 onward returns ALL of the buyer's own reviews regardless of state (verified). This is what we want for Plan 2: the buyer needs to see their hidden reviews so the "Hidden by admin" marker + appeal-via-support flow works. No change.
  - `api/app/Support/Enums/NotificationCategory.php` already includes `case Reviews = 'reviews'` (line 19, added in Plan 1). Plan 2 reuses it for both new notifications. No new category.
  - `api/app/Modules/Notifications/Services/AdminRecipients.php` — `all(): Collection<User>` returns all users with the `admin` role via `spatie/laravel-permission`. Plan 2's `ReviewReportedNotification` fans out via `Notification::send(app(AdminRecipients::class)->all(), ...)`.
  - `api/app/Modules/Admin/routes.php` lines 16–52 host the admin route group under `auth:sanctum + admin + /admin`. Plan 2 adds three new routes inside this group (lines 40–41 inserted alongside the existing returns routes).
  - `api/app/Modules/Returns/Services/ReturnEscalationService.php` is the closest precedent for the resolve flow (read it before implementing Phase B). Same shape: assert admin role, assert state is `Open`, mutate row + cascade to a side-effect (admin resolution) + activity log + fanout notification, all inside `DB::transaction`.
  - `web/src/components/admin/admin-resolve-return-dialog.tsx` is the closest precedent for `<AdminResolveReviewDialog>`. Same UX: action radio + resolution textarea (min 10 chars) + submit button + error display.
  - `web/src/components/admin/confirm-with-justification-dialog.tsx` is reused for the per-row Hide / Restore actions on the admin store-detail Reviews tab.
- No other dependencies. The Stripe/EasyPost/Typesense layers are untouched by Plan 2.

**Successor plan:** `2026-XX-XX-layer-12-reviews-discovery.md` (Plan 3) — pushes `store.average_rating + review_count + four dimension averages` onto Typesense item rows so listing cards can render the inline `★ 4.8 (142)` badge; adds Scout sync hooks so a Store aggregate change queues all of that store's items for reindex; adds the buyer-self-edit activity-log row (`review.buyer_edited` with a diff in properties) for fraud investigation; finalises photo attachment integration in the public reviews list; adds the seller dashboard polish (dimension breakdown card + per-review report-status badge so the seller can see when one of their reviews has been reported + resolved); adds the bulk-hide admin endpoint (`POST /v1/admin/reviews/bulk-hide`, capped at 50 ids + required justification) for spam-storm scenarios; adds an activity-log filter on `/admin/activity` for review-related entries.

---

## Phase A — Schema

### Task 1: `ReviewState::Hidden` enum case + Postgres CHECK relax migration

**Files:**
- Update: `api/app/Support/Enums/ReviewState.php` (add `case Hidden = 'hidden';`)
- Create: `api/database/migrations/2026_05_13_200001_relax_reviews_state_check.php`
- Test: `api/tests/Feature/Reviews/ReviewStateHiddenTest.php`

Plan 1 shipped the enum with a single case. The model already casts `state` to `ReviewState::class` and the migration already added the three companion columns (`hidden_by_admin_id`, `hidden_at`, `hide_reason`) — verified above. Plan 2 needs only the enum widening + the CHECK relax.

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReviewState: string
{
    case Visible = 'visible';
    case Hidden = 'hidden';
}
```

Migration:

```php
<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        if (DB::connection()->getDriverName() !== 'pgsql') {
            return;
        }

        // Plan 1's CHECK was `state IN ('visible')`. Widen to allow 'hidden' too.
        // Postgres requires drop-then-recreate; ALTER CONSTRAINT can't change the
        // expression in place.
        DB::statement('ALTER TABLE reviews DROP CONSTRAINT IF EXISTS reviews_state_check');
        DB::statement("ALTER TABLE reviews ADD CONSTRAINT reviews_state_check CHECK (state IN ('visible', 'hidden'))");
    }

    public function down(): void
    {
        if (DB::connection()->getDriverName() !== 'pgsql') {
            return;
        }

        DB::statement('ALTER TABLE reviews DROP CONSTRAINT IF EXISTS reviews_state_check');
        DB::statement("ALTER TABLE reviews ADD CONSTRAINT reviews_state_check CHECK (state IN ('visible'))");
    }
};
```

> **Plan note (CHECK gated on `pgsql`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Matches Plan 1's pattern at `2026_05_13_100001_create_reviews_table.php` line 44. SQLite local runs don't enforce CHECK so the migration short-circuits there. The constraint name `reviews_state_check` matches Plan 1's choice (line 48). If Plan 1's name differs in your local checkout, adjust both `DROP` statements accordingly — the `DROP IF EXISTS` makes this safe to re-run.

> **Plan note (no fresh-install consolidation):** We do NOT edit Plan 1's migration file to embed the wider CHECK directly. A separate migration ensures historical replay (e.g., a CI environment that diffs schema before/after Plan 1 vs after Plan 2) sees the widening as a discrete event. This matches Layer 11's posture on incremental schema evolution.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Reviews;

use App\Models\Review;
use App\Support\Enums\ReviewState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ReviewStateHiddenTest extends TestCase
{
    use RefreshDatabase;

    public function test_review_state_enum_includes_hidden(): void
    {
        $this->assertEqualsCanonicalizing(
            ['visible', 'hidden'],
            array_map(fn ($c) => $c->value, ReviewState::cases()),
        );
    }

    public function test_review_can_be_persisted_with_hidden_state_on_pgsql(): void
    {
        if (\DB::connection()->getDriverName() !== 'pgsql') {
            $this->markTestSkipped('CHECK constraint only runs on pgsql.');
        }

        $review = Review::factory()->create(['state' => ReviewState::Hidden]);
        $this->assertSame(ReviewState::Hidden, $review->fresh()->state);
    }

    public function test_invalid_state_value_still_rejected_on_pgsql(): void
    {
        if (\DB::connection()->getDriverName() !== 'pgsql') {
            $this->markTestSkipped('CHECK constraint only runs on pgsql.');
        }

        $this->expectException(\Illuminate\Database\QueryException::class);
        \DB::table('reviews')->insert([
            'id' => \Illuminate\Support\Str::uuid()->toString(),
            'order_item_id' => \App\Models\OrderItem::factory()->create()->id,
            'order_id' => \App\Models\Order::factory()->create()->id,
            'store_id' => \App\Models\Store::factory()->create()->id,
            'reviewer_user_id' => \App\Models\User::factory()->create()->id,
            'rating' => 5,
            'rating_item_as_described' => 5,
            'rating_shipping_speed' => 5,
            'rating_communication' => 5,
            'rating_packaging' => 5,
            'body' => 'twenty-character body filler twenty-character',
            'state' => 'flagged',  // not in the widened CHECK
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }
}
```

- [ ] **Step 2: Run, confirm failure** — `Hidden` case missing on enum (first test). The Postgres tests will additionally fail on persist because Plan 1's CHECK rejects `'hidden'`.

- [ ] **Step 3: Implement** the enum widening + migration. Run `docker compose exec -T laravel.test php artisan migrate` to apply.

- [ ] **Step 4: Run; iterate to 3/3 PASS** (SQLite skip is acceptable; pgsql is the CI target).

---

### Task 2: `review_reports` table + `ReportReason`, `ReviewReportState`, `ReviewAdminAction` enums

**Files:**
- Create: `api/database/migrations/2026_05_13_200002_create_review_reports_table.php`
- Create: `api/app/Support/Enums/ReportReason.php`
- Create: `api/app/Support/Enums/ReviewReportState.php`
- Create: `api/app/Support/Enums/ReviewAdminAction.php`
- Test: `api/tests/Feature/Reviews/ReviewReportSchemaTest.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('review_reports', function (Blueprint $t) {
            $t->uuid('id')->primary();
            $t->foreignUuid('review_id')->constrained('reviews')->cascadeOnDelete();
            $t->foreignUuid('reported_by_user_id')->constrained('users');

            $t->string('reason', 32);                   // ReportReason enum-backed
            $t->text('reason_text')->nullable();        // required at FormRequest when reason === 'other'

            $t->string('state', 16)->default('open');   // ReviewReportState enum-backed
            $t->foreignUuid('resolved_by_admin_id')->nullable()->constrained('users');
            $t->timestamp('resolved_at')->nullable();
            $t->string('action', 16)->nullable();       // ReviewAdminAction enum-backed (null until resolved)
            $t->text('resolution_note')->nullable();
            $t->timestamps();

            // The admin queue's primary read path: open reports first, oldest open at top.
            $t->index(['state', 'created_at'], 'review_reports_state_created_idx');
            // The per-review duplicate-check + auto-resolve-siblings lookup.
            $t->index(['review_id', 'state'], 'review_reports_review_state_idx');
        });

        if (DB::connection()->getDriverName() === 'pgsql') {
            DB::statement("ALTER TABLE review_reports ADD CONSTRAINT review_reports_reason_check CHECK (reason IN ('inappropriate', 'spam', 'not_about_purchase', 'personal_info', 'other'))");
            DB::statement("ALTER TABLE review_reports ADD CONSTRAINT review_reports_state_check CHECK (state IN ('open', 'resolved'))");
            DB::statement("ALTER TABLE review_reports ADD CONSTRAINT review_reports_action_check CHECK (action IS NULL OR action IN ('keep', 'hide'))");
        }
    }

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

Enums:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReportReason: string
{
    case Inappropriate = 'inappropriate';
    case Spam = 'spam';
    case NotAboutPurchase = 'not_about_purchase';
    case PersonalInfo = 'personal_info';
    case Other = 'other';
}
```

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReviewReportState: string
{
    case Open = 'open';
    case Resolved = 'resolved';
}
```

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReviewAdminAction: string
{
    case Keep = 'keep';
    case Hide = 'hide';
}
```

> **Plan note (`ReviewAdminAction` distinct from `ReturnAdminAction`) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** `App\Support\Enums\ReturnAdminAction` already exists (cases: `ForceRefund | ForceCloseNoRefund | NoAction`) for Layer 10. Plan 2 introduces a separate `ReviewAdminAction` enum (cases: `Keep | Hide`) because the two are different domain concepts that would only confuse readers if merged. Two-case enum is intentional — restoring a hidden review is a separate code path (a dedicated `POST /v1/admin/reviews/{review}/restore` endpoint with no enum, only a free-text reason), not a third action on this enum.

> **Plan note (no unique index on `(review_id, reported_by_user_id, state)`):** We enforce "one open report per reporter per review" at the service layer (`ReviewReportService::open` does a `where(...)->where(...)->where('state', 'open')->exists()` check inside the transaction) rather than via a partial unique index. Rationale: Postgres partial indexes are supported but SQLite isn't, so a partial-unique approach would diverge between drivers; the application-layer check is portable and gives us a friendly 422 message instead of a 500 from a unique-violation. The non-unique `review_reports_review_state_idx` index covers the lookup.

> **Plan note (CHECK on `action` allows NULL):** `action` is null until the report is resolved. The CHECK at line 33 of the migration permits `IS NULL OR action IN ('keep', 'hide')` so the open-report default writes cleanly. Mirrors Plan 1's `'state' IN ('visible')` pattern with the addition of the nullable branch.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Reviews;

use App\Support\Enums\ReportReason;
use App\Support\Enums\ReviewAdminAction;
use App\Support\Enums\ReviewReportState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class ReviewReportSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_review_reports_table_exists_with_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('review_reports'));
        foreach ([
            'id', 'review_id', 'reported_by_user_id',
            'reason', 'reason_text', 'state',
            'resolved_by_admin_id', 'resolved_at', 'action', 'resolution_note',
            'created_at', 'updated_at',
        ] as $col) {
            $this->assertTrue(Schema::hasColumn('review_reports', $col), "review_reports.$col missing");
        }
    }

    public function test_report_reason_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['inappropriate', 'spam', 'not_about_purchase', 'personal_info', 'other'],
            array_map(fn ($c) => $c->value, ReportReason::cases()),
        );
    }

    public function test_review_report_state_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['open', 'resolved'],
            array_map(fn ($c) => $c->value, ReviewReportState::cases()),
        );
    }

    public function test_review_admin_action_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['keep', 'hide'],
            array_map(fn ($c) => $c->value, ReviewAdminAction::cases()),
        );
    }

    public function test_invalid_reason_rejected_on_pgsql(): void
    {
        if (\DB::connection()->getDriverName() !== 'pgsql') {
            $this->markTestSkipped('CHECK constraint only runs on pgsql.');
        }
        $review = \App\Models\Review::factory()->create();
        $reporter = \App\Models\User::factory()->create();
        $this->expectException(\Illuminate\Database\QueryException::class);
        \DB::table('review_reports')->insert([
            'id' => \Illuminate\Support\Str::uuid()->toString(),
            'review_id' => $review->id,
            'reported_by_user_id' => $reporter->id,
            'reason' => 'not_a_reason',
            'state' => 'open',
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }
}
```

5 tests.

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

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

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

---

### Task 3: `ReviewReport` model + factory

**Files:**
- Create: `api/app/Models/ReviewReport.php`
- Create: `api/database/factories/ReviewReportFactory.php`
- Update: `api/app/Models/Review.php` (add `reports(): HasMany` relation)
- Test: `api/tests/Feature/Reviews/ReviewReportModelTest.php`

Model:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Enums\ReportReason;
use App\Support\Enums\ReviewAdminAction;
use App\Support\Enums\ReviewReportState;
use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * @property string $id
 * @property string $review_id
 * @property string $reported_by_user_id
 * @property ReportReason $reason
 * @property string|null $reason_text
 * @property ReviewReportState $state
 * @property string|null $resolved_by_admin_id
 * @property Carbon|null $resolved_at
 * @property ReviewAdminAction|null $action
 * @property string|null $resolution_note
 * @property Carbon $created_at
 * @property Carbon $updated_at
 * @property-read Review $review
 * @property-read User $reportedBy
 * @property-read User|null $resolvedByAdmin
 */
class ReviewReport extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'review_id',
        'reported_by_user_id',
        'reason',
        'reason_text',
        'state',
        'resolved_by_admin_id',
        'resolved_at',
        'action',
        'resolution_note',
    ];

    protected function casts(): array
    {
        return [
            'reason' => ReportReason::class,
            'state' => ReviewReportState::class,
            'action' => ReviewAdminAction::class,
            'resolved_at' => 'datetime',
        ];
    }

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

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

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

Factory (coherent default chain — every default report references a brand-new Review by a brand-new reporter; tests that need specific actors override via state methods):

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Review;
use App\Models\ReviewReport;
use App\Models\User;
use App\Support\Enums\ReportReason;
use App\Support\Enums\ReviewReportState;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<ReviewReport>
 */
class ReviewReportFactory extends Factory
{
    protected $model = ReviewReport::class;

    public function definition(): array
    {
        return [
            'id' => fake()->uuid(),
            'review_id' => Review::factory(),
            'reported_by_user_id' => User::factory(),  // a different user than the review's reviewer by default
            'reason' => fake()->randomElement(ReportReason::cases())->value,
            'reason_text' => null,
            'state' => ReviewReportState::Open->value,
            'resolved_by_admin_id' => null,
            'resolved_at' => null,
            'action' => null,
            'resolution_note' => null,
        ];
    }

    public function forReview(Review $review): self
    {
        return $this->state(fn () => ['review_id' => $review->id]);
    }

    public function byReporter(User $reporter): self
    {
        return $this->state(fn () => ['reported_by_user_id' => $reporter->id]);
    }

    public function withReason(ReportReason $reason, ?string $reasonText = null): self
    {
        return $this->state(fn () => [
            'reason' => $reason->value,
            'reason_text' => $reasonText,
        ]);
    }

    public function resolved(User $admin, \App\Support\Enums\ReviewAdminAction $action, string $note): self
    {
        return $this->state(fn () => [
            'state' => ReviewReportState::Resolved->value,
            'resolved_by_admin_id' => $admin->id,
            'resolved_at' => now(),
            'action' => $action->value,
            'resolution_note' => $note,
        ]);
    }
}
```

> **Plan note (factory does NOT auto-distinguish reporter from reviewer):** The `User::factory()` default for `reported_by_user_id` produces a different user by construction (different uuid). Tests asserting "buyer cannot report own review" must explicitly set `reported_by_user_id` to the Review's `reviewer_user_id` to exercise that 422 path.

`Review` model — add the `HasMany` relation alongside the existing relations:

```php
public function reports(): \Illuminate\Database\Eloquent\Relations\HasMany
{
    return $this->hasMany(ReviewReport::class);
}
```

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

```php
public function test_factory_default_creates_open_report_with_random_reason(): void
public function test_for_review_state_targets_specific_review(): void
public function test_by_reporter_state_targets_specific_user(): void
public function test_with_reason_state_sets_reason_and_text(): void
public function test_resolved_state_sets_admin_action_note_timestamp(): void
public function test_review_reports_relation_returns_hasmany(): void
public function test_state_casts_to_review_report_state_enum(): void
public function test_action_casts_to_review_admin_action_enum_when_resolved(): void
public function test_reason_casts_to_report_reason_enum(): void
```

9 tests.

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

- [ ] **Step 3: Implement** the model + factory + the `Review::reports()` relation.

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

---

## Phase B — Services

### Task 4: Expose `ReviewWriter::recomputeAggregates` for cross-service reuse

**Files:**
- Update: `api/app/Modules/Reviews/Services/ReviewWriter.php` (widen `recomputeAggregates` from `private` to `public`)
- Test: `api/tests/Feature/Reviews/ReviewWriterRecomputeVisibilityTest.php`

> **Plan note (widening vs extracting) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** Two ways to share Plan 1's aggregate-recompute helper with Plan 2's `ReviewReportService`. (a) Widen `ReviewWriter::recomputeAggregates(Store)` from `private` to `public`. (b) Extract the recompute into a separate `StoreReviewAggregator` service that both `ReviewWriter` and `ReviewReportService` depend on. **Plan 2 picks (a)** — minimum surface change, no new abstraction, `ReviewWriter` is `final` so widening visibility carries no subclass risk. The plan note that originally questioned visibility is preserved: if the user prefers (b), the change is mechanical (extract the method body into `StoreReviewAggregator::recomputeFor(Store)`, inject it into both services, drop the now-redundant `ReviewWriter` private method) and the rest of Plan 2 swaps the call site without re-reading.

Method change (the body is unchanged from Plan 1; only the visibility keyword changes):

```php
// Before (Plan 1):
private function recomputeAggregates(Store $store): void

// After (Plan 2):
public function recomputeAggregates(Store $store): void
```

The existing filter `$store->reviews()->where('state', 'visible')->get()` is **already correct** for Plan 2 — hidden reviews are excluded from the count + every average. Verified by reading line 171 of `ReviewWriter.php`. No body changes needed.

- [ ] **Step 1: Write the failing test** — a small smoke test that asserts the aggregate excludes hidden reviews. (It will pass after Task 1 if Plan 1's filter is correct, but writing the test anchors the behaviour against future regressions.)

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Reviews;

use App\Models\Review;
use App\Models\Store;
use App\Modules\Reviews\Services\ReviewWriter;
use App\Support\Enums\ReviewState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ReviewWriterRecomputeVisibilityTest extends TestCase
{
    use RefreshDatabase;

    public function test_recompute_excludes_hidden_reviews_from_count_and_averages(): void
    {
        $store = Store::factory()->create();
        // 3 visible at rating 5, 1 hidden at rating 1 — averages should ignore the hidden row.
        Review::factory()->forStore($store)->count(3)->create(['rating' => 5, 'state' => ReviewState::Visible]);
        Review::factory()->forStore($store)->create(['rating' => 1, 'state' => ReviewState::Hidden]);

        app(ReviewWriter::class)->recomputeAggregates($store);

        $store->refresh();
        $this->assertSame(3, $store->review_count);
        $this->assertSame(5.0, $store->average_rating);
    }

    public function test_recompute_is_now_publicly_callable(): void
    {
        $reflection = new \ReflectionMethod(ReviewWriter::class, 'recomputeAggregates');
        $this->assertTrue($reflection->isPublic(), 'recomputeAggregates must be public for Phase B service reuse');
    }
}
```

2 tests.

- [ ] **Step 2: Run, confirm failure** — the public-visibility assertion fails until the change lands; the exclusion test may already pass against Plan 1's filter.

- [ ] **Step 3: Implement** the visibility widening.

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

---

### Task 5: `ReviewReportService::open` — buyer (or any logged-in user) submits a report

**Files:**
- Create: `api/app/Modules/Reviews/Services/ReviewReportService.php`
- Create: `api/app/Modules/Notifications/Notifications/ReviewReportedNotification.php` (stub — full body in Phase D, but the service depends on the class name being importable)
- Test: `api/tests/Feature/Reviews/ReviewReportServiceOpenTest.php`

Service (open method only; resolve + restore land in Task 6):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Services;

use App\Models\Review;
use App\Models\ReviewReport;
use App\Models\User;
use App\Modules\Notifications\Notifications\ReviewReportedNotification;
use App\Modules\Notifications\Services\AdminRecipients;
use App\Support\Enums\ReportReason;
use App\Support\Enums\ReviewReportState;
use App\Support\Enums\ReviewState;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;

final class ReviewReportService
{
    public function __construct(
        private readonly ReviewWriter $writer,
        private readonly AdminRecipients $admins,
    ) {}

    public function open(Review $review, User $reporter, ReportReason $reason, ?string $reasonText): ReviewReport
    {
        // 1. Cannot report a hidden review (it's already moderated).
        if ($review->state === ReviewState::Hidden) {
            abort(422, 'Review is no longer visible.');
        }

        // 2. Cannot report your own review.
        if ($reporter->id === $review->reviewer_user_id) {
            abort(422, 'You cannot report your own review.');
        }

        // 3. `reason = other` requires `reason_text`.
        if ($reason === ReportReason::Other && trim((string) $reasonText) === '') {
            abort(422, 'A description is required when reason is "other".');
        }

        // 4. One open report per reporter per review (application-layer rate limit).
        $hasOpen = ReviewReport::query()
            ->where('review_id', $review->id)
            ->where('reported_by_user_id', $reporter->id)
            ->where('state', ReviewReportState::Open->value)
            ->exists();
        if ($hasOpen) {
            abort(422, 'You already have an open report on this review.');
        }

        return DB::transaction(function () use ($review, $reporter, $reason, $reasonText) {
            $report = ReviewReport::query()->create([
                'review_id' => $review->id,
                'reported_by_user_id' => $reporter->id,
                'reason' => $reason->value,
                'reason_text' => $reasonText,
                'state' => ReviewReportState::Open->value,
            ]);

            // Fan out to admins. Notification class is queueable → actual dispatch
            // happens after the transaction commits. Empty admin set is a no-op.
            $recipients = $this->admins->all();
            if ($recipients->isNotEmpty()) {
                Notification::send($recipients, new ReviewReportedNotification($report->fresh(['review', 'reportedBy'])));
            }

            return $report->fresh(['review', 'reportedBy']);
        });
    }
}
```

> **Plan note (one open report per reporter per review) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** The spec says "duplicate reports allowed; dedupe at queue level." Plan 2 interprets this as: a single reporter can have at most ONE `open` report against a given review at a time. Re-reporting after their first open report is rejected with 422 `'You already have an open report on this review.'` Different reporters CAN each open their own report on the same review (the admin's queue dedupes visually by `(review_id × open-reports group)` and auto-resolves siblings on resolve — see Task 6). After a reporter's open report is `resolved`, they can theoretically open a new one if the content recurs, but in practice the review itself would then be `hidden` (action = hide) and the hidden-review guard would block the new report. This is the deliberate spam-prevention posture.

> **Plan note (cannot report own review):** Spec doesn't spell this out explicitly, but it would be absurd to let a buyer self-report to get themselves into the admin queue. The 422 + plain-English message is the friendly path. See architectural-decisions section.

> **Plan note (cannot report a hidden review):** The review is already moderated; the report would clutter the queue with redundant work. 422 with `'Review is no longer visible.'` matches the message a UI would render after the review's state flips while the user was about to click Report.

> **Plan note (admin reporting is allowed):** No role gate beyond `auth:sanctum`. Admins typically report via internal channels (their own admin tools), but if an admin notices a flagrant review while browsing the public store page, the Report button is rendered the same and POSTs the same. The notification fans out to all admins including the reporter, which is fine — the reporter would see "you reported this" in their own bell and the rest of the team sees it as the queue. No special case.

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

```php
public function test_open_creates_report_in_open_state(): void
public function test_open_persists_reason_and_reason_text(): void
public function test_open_with_reason_other_requires_reason_text(): void
public function test_open_with_reason_other_empty_string_rejected(): void
public function test_open_with_reason_other_and_text_succeeds(): void
public function test_cannot_report_hidden_review(): void
public function test_cannot_report_own_review(): void
public function test_duplicate_open_report_by_same_reporter_returns_422(): void
public function test_resolved_then_reopen_is_allowed(): void
public function test_two_different_reporters_can_both_open_reports_on_same_review(): void
public function test_open_fans_notification_out_to_all_admins(): void
public function test_open_with_empty_admins_does_not_throw(): void
public function test_open_is_transactional_rolls_back_on_notification_failure(): void
```

13 tests.

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

- [ ] **Step 3: Implement** the service + a stub `ReviewReportedNotification` class (the empty `via()` + the constructor signature). The full notification body is implemented in Phase D — for now the stub just exists so `Notification::send` doesn't fatal.

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

---

### Task 6: `ReviewReportService::resolve` + `::restore` — admin moderation actions

**Files:**
- Update: `api/app/Modules/Reviews/Services/ReviewReportService.php` (add `resolve` and `restore` methods)
- Create: `api/app/Modules/Notifications/Notifications/ReviewHiddenNotification.php` (stub — full body in Phase D)
- Test: `api/tests/Feature/Reviews/ReviewReportServiceResolveTest.php`
- Test: `api/tests/Feature/Reviews/ReviewReportServiceRestoreTest.php`

Resolve:

```php
public function resolve(
    ReviewReport $report,
    User $admin,
    ReviewAdminAction $action,
    string $resolutionNote,
): ReviewReport {
    if (! $admin->hasRole('admin')) {
        abort(403, 'Only admins can resolve review reports.');
    }
    if ($report->state !== ReviewReportState::Open) {
        abort(422, 'This report has already been resolved.');
    }
    $resolutionNote = trim($resolutionNote);
    if (mb_strlen($resolutionNote) < 10) {
        abort(422, 'Resolution note must be at least 10 characters.');
    }
    if (mb_strlen($resolutionNote) > 2000) {
        abort(422, 'Resolution note may not exceed 2000 characters.');
    }

    return DB::transaction(function () use ($report, $admin, $action, $resolutionNote) {
        // 1. Resolve the primary report.
        $report->update([
            'state' => ReviewReportState::Resolved->value,
            'resolved_by_admin_id' => $admin->id,
            'resolved_at' => now(),
            'action' => $action->value,
            'resolution_note' => $resolutionNote,
        ]);

        $review = $report->review()->lockForUpdate()->first();
        if ($review === null) {
            abort(404, 'Review no longer exists.');
        }

        // 2. If hiding, flip the review's state + recompute the store aggregate.
        if ($action === ReviewAdminAction::Hide && $review->state !== ReviewState::Hidden) {
            $store = Store::query()
                ->whereKey($review->store_id)
                ->lockForUpdate()
                ->firstOrFail();

            $review->update([
                'state' => ReviewState::Hidden->value,
                'hidden_by_admin_id' => $admin->id,
                'hidden_at' => now(),
                'hide_reason' => $resolutionNote,
            ]);

            $this->writer->recomputeAggregates($store);

            // Buyer notification — queueable, dispatches after commit.
            $reviewer = $review->fresh(['reviewer'])->reviewer;
            if ($reviewer !== null) {
                Notification::send($reviewer, new ReviewHiddenNotification($review, $resolutionNote));
            }

            // Activity log — admin-scoped log_name to match Layers 8 / 10 / 11.
            activity('admin')
                ->causedBy($admin)
                ->performedOn($review)
                ->withProperties([
                    'review_id' => $review->id,
                    'report_id' => $report->id,
                    'hide_reason' => $resolutionNote,
                ])
                ->log('review.admin_hidden');
        }

        // 3. Always write the `report_resolved` log (regardless of action).
        activity('admin')
            ->causedBy($admin)
            ->performedOn($review)
            ->withProperties([
                'review_id' => $review->id,
                'report_id' => $report->id,
                'action' => $action->value,
                'resolution_note' => $resolutionNote,
            ])
            ->log('review.report_resolved');

        // 4. Auto-resolve sibling open reports against the same review.
        $siblings = ReviewReport::query()
            ->where('review_id', $review->id)
            ->where('state', ReviewReportState::Open->value)
            ->where('id', '!=', $report->id)
            ->get();

        foreach ($siblings as $sibling) {
            $sibling->update([
                'state' => ReviewReportState::Resolved->value,
                'resolved_by_admin_id' => $admin->id,
                'resolved_at' => now(),
                'action' => $action->value,
                'resolution_note' => "Auto-resolved alongside report {$report->id} (action: {$action->value}).",
            ]);

            // No additional notifications/log lines for siblings — the primary
            // resolve has already covered the audit trail.
        }

        return $report->fresh(['review', 'reportedBy', 'resolvedByAdmin']);
    });
}
```

> **Plan note (auto-resolve siblings is independent of action) — `[USER SHOULD CONFIRM BEFORE IMPLEMENTATION]`:** When the admin resolves report A with `keep`, every OTHER open report on the same review also flips to `resolved` with `action = keep` and the synthesized note. Same for `hide`. The admin gets a single click per review, not per report. The note carries the primary-report id so a future fraud auditor can reconstruct the chain. Sibling resolutions do NOT fire additional notifications or activity-log rows — those are scoped to the primary resolve to avoid notification storms.

> **Plan note (hide is idempotent within the resolve path):** If a Review is somehow already `Hidden` when a report arrives (race? prior plan-3 bulk hide?), `resolve` with `hide` short-circuits the state flip + recompute + log (the `if ($review->state !== ReviewState::Hidden)` guard) but still resolves the report itself. This prevents double-decrementing the aggregate.

> **Plan note (shared `applyHide` helper):** The Hide branch of `resolve` (lines under `if ($action === ReviewAdminAction::Hide && $review->state !== ReviewState::Hidden)`) — state flip + `recomputeAggregates` + `ReviewHiddenNotification` send + `review.admin_hidden` activity log — is the **exact same shape** required by Task 9b's direct-hide path (`ReviewReportService::adminHide`). Extract these four side-effects into a private helper `applyHide(Review $review, Store $store, User $admin, string $reason): void` while implementing Task 6 so Task 9b can call it without duplication. The `resolve` path passes `$resolutionNote` as `$reason`; the `adminHide` path passes its `$reason` arg directly. Both paths produce identical hide_reason text + identical activity-log properties (with the report_id property simply omitted on the direct-hide path — adjust the helper to accept `?string $reportId = null` and only include `report_id` in `withProperties` when non-null).

Restore:

```php
public function restore(Review $review, User $admin, string $reason): Review
{
    if (! $admin->hasRole('admin')) {
        abort(403, 'Only admins can restore hidden reviews.');
    }
    if ($review->state !== ReviewState::Hidden) {
        abort(422, 'Review is not hidden.');
    }
    $reason = trim($reason);
    if (mb_strlen($reason) < 10) {
        abort(422, 'Restore reason must be at least 10 characters.');
    }
    if (mb_strlen($reason) > 2000) {
        abort(422, 'Restore reason may not exceed 2000 characters.');
    }

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

        $review->update([
            'state' => ReviewState::Visible->value,
            'hidden_by_admin_id' => null,
            'hidden_at' => null,
            'hide_reason' => null,
        ]);

        $this->writer->recomputeAggregates($store);

        activity('admin')
            ->causedBy($admin)
            ->performedOn($review)
            ->withProperties([
                'review_id' => $review->id,
                'restore_reason' => $reason,
            ])
            ->log('review.admin_restored');

        return $review->fresh();
    });
}
```

> **Plan note (restore does NOT notify the buyer):** When a previously-hidden review is restored, the buyer's review is publicly visible again. We deliberately don't notify them: the original hide notification was a corrective action; restoring it doesn't warrant a second mail. If the user wants the inverse symmetry ("Your review has been restored"), it's a small follow-up.

> **Plan note (restore does NOT reopen sibling reports):** Reports against the now-restored review remain resolved with `action = hide`. If the content surfaces a new complaint, a new report opens. We don't try to retroactively un-resolve historical reports — too much complexity, and the audit trail stays cleaner if resolved-then-changed-mind is reflected as two discrete events (`review.admin_hidden` followed by `review.admin_restored`).

- [ ] **Step 1: Write the failing tests** — Resolve test file:

```php
public function test_resolve_keep_marks_report_resolved_without_touching_review(): void
public function test_resolve_hide_flips_review_to_hidden_state(): void
public function test_resolve_hide_decrements_store_review_count(): void
public function test_resolve_hide_recomputes_store_average_rating_excluding_hidden(): void
public function test_resolve_hide_recomputes_all_four_dimension_averages(): void
public function test_resolve_hide_writes_hidden_by_admin_id_and_hidden_at(): void
public function test_resolve_hide_writes_hide_reason_to_review(): void
public function test_resolve_hide_fires_review_hidden_notification_to_buyer(): void
public function test_resolve_writes_review_report_resolved_activity_log(): void
public function test_resolve_hide_also_writes_review_admin_hidden_activity_log(): void
public function test_resolve_keep_does_NOT_write_review_admin_hidden_activity_log(): void
public function test_resolve_auto_resolves_sibling_open_reports_with_same_action(): void
public function test_resolve_sibling_auto_resolution_includes_primary_id_in_note(): void
public function test_resolve_auto_resolution_uses_keep_action_for_keep_path(): void
public function test_resolve_by_non_admin_returns_403(): void
public function test_resolve_already_resolved_report_returns_422(): void
public function test_resolve_with_short_note_returns_422(): void
public function test_resolve_with_oversized_note_returns_422(): void
public function test_resolve_hide_is_idempotent_when_review_already_hidden(): void
public function test_resolve_is_transactional_rolls_back_on_failure(): void
public function test_resolve_does_NOT_fire_notification_for_sibling_reports(): void
```

21 tests. Restore test file:

```php
public function test_restore_flips_hidden_review_back_to_visible(): void
public function test_restore_recomputes_store_aggregate_to_include_restored_row(): void
public function test_restore_clears_hidden_by_admin_id_hidden_at_hide_reason(): void
public function test_restore_writes_review_admin_restored_activity_log(): void
public function test_restore_on_visible_review_returns_422(): void
public function test_restore_by_non_admin_returns_403(): void
public function test_restore_with_short_reason_returns_422(): void
public function test_restore_with_oversized_reason_returns_422(): void
public function test_restore_does_NOT_notify_buyer(): void
public function test_restore_does_NOT_reopen_resolved_reports(): void
```

10 tests.

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

- [ ] **Step 3: Implement.** Mock `Notification::fake()` for the notification assertions. Use `Activity::query()->where('description', 'review.admin_hidden')->latest()->first()` for the activity-log assertion (matches Layer 10's pattern).

- [ ] **Step 4: Run; iterate to 31/31 PASS** (21 resolve + 10 restore).

---

## Phase C — Buyer + Admin Endpoints

### Task 7: `POST /v1/reviews/{review}/reports` — buyer (or any logged-in user) reports a review

**Files:**
- Create: `api/app/Modules/Reviews/Controllers/ReviewReportController.php`
- Create: `api/app/Modules/Reviews/Requests/StoreReviewReportRequest.php`
- Create: `api/app/Modules/Reviews/Resources/ReviewReportResource.php`
- Update: `api/app/Modules/Reviews/routes.php` (add the route inside the existing `auth:sanctum` group)
- Test: `api/tests/Feature/Reviews/ReviewReportEndpointTest.php`

Request:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Requests;

use App\Support\Enums\ReportReason;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreReviewReportRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'reason' => ['required', Rule::enum(ReportReason::class)],
            'reason_text' => ['nullable', 'string', 'max:2000', 'required_if:reason,other'],
        ];
    }

    public function messages(): array
    {
        return [
            'reason_text.required_if' => 'A description is required when reason is "other".',
        ];
    }
}
```

Controller:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Controllers;

use App\Models\Review;
use App\Modules\Reviews\Requests\StoreReviewReportRequest;
use App\Modules\Reviews\Resources\ReviewReportResource;
use App\Modules\Reviews\Services\ReviewReportService;
use App\Support\Enums\ReportReason;
use Illuminate\Http\JsonResponse;

final class ReviewReportController
{
    public function __construct(private readonly ReviewReportService $reports) {}

    public function store(StoreReviewReportRequest $request, Review $review): JsonResponse
    {
        $report = $this->reports->open(
            $review,
            $request->user(),
            ReportReason::from($request->validated('reason')),
            $request->validated('reason_text'),
        );

        return (new ReviewReportResource($report))
            ->response()
            ->setStatusCode(201);
    }
}
```

Resource (the API response shape; mirrors `ReturnEscalationResource`):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Resources;

use App\Models\ReviewReport;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * @mixin ReviewReport
 */
class ReviewReportResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'review_id' => $this->review_id,
            'reported_by_user_id' => $this->reported_by_user_id,
            'reason' => $this->reason->value,
            'reason_text' => $this->reason_text,
            'state' => $this->state->value,
            'resolved_by_admin_id' => $this->resolved_by_admin_id,
            'resolved_at' => $this->resolved_at?->toISOString(),
            'action' => $this->action?->value,
            'resolution_note' => $this->resolution_note,
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
        ];
    }
}
```

Routes — add inside the existing `auth:sanctum` group at `api/app/Modules/Reviews/routes.php`:

```php
Route::post('/reviews/{review}/reports', [ReviewReportController::class, 'store']);
```

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

```php
public function test_post_creates_a_report_and_returns_201(): void
public function test_post_persists_reason_and_reason_text(): void
public function test_post_requires_auth(): void
public function test_post_requires_reason(): void
public function test_post_rejects_invalid_reason(): void
public function test_post_requires_reason_text_when_reason_is_other(): void
public function test_post_accepts_reason_other_with_text(): void
public function test_post_rejects_when_reporter_is_review_author(): void
public function test_post_rejects_when_review_is_hidden(): void
public function test_post_rejects_duplicate_open_report_from_same_reporter(): void
public function test_post_allows_second_reporter_to_report_same_review(): void
public function test_post_fans_notification_to_admins(): void
public function test_post_response_includes_state_open_and_no_action(): void
```

13 tests.

- [ ] **Step 2: Run, confirm failure** — route + controller missing.

- [ ] **Step 3: Implement** the request + controller + resource + route entry.

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

---

### Task 8: `GET /v1/admin/review-reports` — admin moderation queue

**Files:**
- Create: `api/app/Modules/Reviews/Controllers/AdminReviewReportController.php`
- Create: `api/app/Modules/Reviews/Resources/ReviewReportSummaryResource.php`
- Update: `api/app/Modules/Admin/routes.php` (add the route inside the existing admin group; sensible position: just before the existing `/returns` line)
- Test: `api/tests/Feature/Reviews/AdminReviewReportIndexTest.php`

Controller (index method only — the resolve method lands in Task 9):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Controllers;

use App\Models\ReviewReport;
use App\Modules\Reviews\Resources\ReviewReportSummaryResource;
use App\Support\Enums\ReviewReportState;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class AdminReviewReportController
{
    public function index(Request $request): JsonResponse
    {
        $stateParam = (string) $request->query('state', 'open');
        $perPage = max(1, min(100, (int) $request->query('per_page', 20)));

        $query = ReviewReport::query()
            ->with([
                'review' => fn ($q) => $q->with(['reviewer:id,name', 'store:id,name,slug']),
                'reportedBy:id,name',
            ])
            ->orderBy('state')          // open first (alphabetically)
            ->orderByDesc('created_at');

        match ($stateParam) {
            'open' => $query->where('state', ReviewReportState::Open->value),
            'resolved' => $query->where('state', ReviewReportState::Resolved->value),
            'all' => null,             // no filter
            default => abort(422, 'Invalid state filter.'),
        };

        $reports = $query->paginate($perPage);

        return ReviewReportSummaryResource::collection($reports)->response();
    }
}
```

Resource:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Resources;

use App\Models\ReviewReport;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * @mixin ReviewReport
 */
class ReviewReportSummaryResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'state' => $this->state->value,
            'reason' => $this->reason->value,
            'reason_text' => $this->reason_text,
            'action' => $this->action?->value,
            'resolution_note' => $this->resolution_note,
            'resolved_at' => $this->resolved_at?->toISOString(),
            'created_at' => $this->created_at->toISOString(),
            'reported_by' => $this->whenLoaded('reportedBy', fn () => [
                'id' => $this->reportedBy->id,
                'name' => $this->reportedBy->name,
            ]),
            'review' => $this->whenLoaded('review', fn () => [
                'id' => $this->review->id,
                'state' => $this->review->state->value,
                'rating' => $this->review->rating,
                'title' => $this->review->title,
                'body' => $this->review->body,
                'reviewer' => [
                    'id' => $this->review->reviewer->id,
                    'name' => $this->review->reviewer->name,
                ],
                'store' => [
                    'id' => $this->review->store->id,
                    'name' => $this->review->store->name,
                    'slug' => $this->review->store->slug,
                ],
                'open_report_count' => $this->review->reports->where('state', 'open')->count(),
            ]),
        ];
    }
}
```

Routes — insert inside the existing `auth:sanctum + admin` group at `api/app/Modules/Admin/routes.php`, just before the `/returns` line:

```php
Route::get('/review-reports', [AdminReviewReportController::class, 'index']);
Route::post('/review-reports/{review_report}/resolve', [AdminReviewReportController::class, 'resolve']);  // Task 9
Route::post('/reviews/{review}/restore', [AdminReviewController::class, 'restore']);                    // Task 9b
```

> **Plan note (where the admin reviews controllers live):** Both new admin controllers (`AdminReviewReportController` + `AdminReviewController`) live under `api/app/Modules/Reviews/Controllers/` for module cohesion. The routes are registered from `Modules/Admin/routes.php` so they pick up the admin middleware + `/admin` prefix automatically. This matches Layer 10's `AdminReturnController` placement (lives in `Modules/Returns/`, registered from `Modules/Admin/routes.php`).

> **Plan note (`open_report_count` on the eager-loaded review):** The queue shows per-review open-report count inline so the admin sees at a glance "this review has 5 open reports" and clicking Resolve auto-handles all of them. **Do NOT add a separate per-review aggregation view** — keeping it one row per report keeps pagination simple and the admin can sort/filter by state. The spec line 200 says "the report queue dedupes by `review_id` for the admin's view"; we honour the intent with the inline count + the auto-resolve-siblings behaviour rather than collapsing rows server-side.

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

```php
public function test_get_returns_open_reports_by_default(): void
public function test_get_with_state_resolved_returns_only_resolved(): void
public function test_get_with_state_all_returns_both(): void
public function test_get_with_invalid_state_returns_422(): void
public function test_get_paginates_with_per_page(): void
public function test_get_requires_admin_role(): void
public function test_get_response_eager_loads_review_reviewer_store(): void
public function test_get_response_includes_open_report_count_per_review(): void
public function test_get_orders_oldest_open_first(): void
```

9 tests.

- [ ] **Step 2: Run, confirm failure** — route + controller missing.

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

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

---

### Task 9: `POST /v1/admin/review-reports/{report}/resolve` + `POST /v1/admin/reviews/{review}/restore`

**Files:**
- Update: `api/app/Modules/Reviews/Controllers/AdminReviewReportController.php` (add `resolve`)
- Create: `api/app/Modules/Reviews/Controllers/AdminReviewController.php` (with `restore`)
- Create: `api/app/Modules/Reviews/Requests/ResolveReviewReportRequest.php`
- Create: `api/app/Modules/Reviews/Requests/RestoreReviewRequest.php`
- Test: `api/tests/Feature/Reviews/AdminReviewReportResolveTest.php`
- Test: `api/tests/Feature/Reviews/AdminReviewRestoreTest.php`

ResolveReviewReportRequest:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Requests;

use App\Support\Enums\ReviewAdminAction;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class ResolveReviewReportRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->hasRole('admin') === true;
    }

    public function rules(): array
    {
        return [
            'action' => ['required', Rule::enum(ReviewAdminAction::class)],
            'resolution_note' => ['required', 'string', 'min:10', 'max:2000'],
        ];
    }
}
```

RestoreReviewRequest:

```php
class RestoreReviewRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->hasRole('admin') === true;
    }

    public function rules(): array
    {
        return [
            'reason' => ['required', 'string', 'min:10', 'max:2000'],
        ];
    }
}
```

AdminReviewReportController::resolve:

```php
public function resolve(
    ResolveReviewReportRequest $request,
    ReviewReport $reviewReport,
    ReviewReportService $service,
): JsonResponse {
    $resolved = $service->resolve(
        $reviewReport,
        $request->user(),
        ReviewAdminAction::from($request->validated('action')),
        $request->validated('resolution_note'),
    );

    return (new ReviewReportResource($resolved))->response();
}
```

AdminReviewController::restore:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Controllers;

use App\Models\Review;
use App\Modules\Reviews\Requests\RestoreReviewRequest;
use App\Modules\Reviews\Resources\ReviewResource;
use App\Modules\Reviews\Services\ReviewReportService;
use Illuminate\Http\JsonResponse;

final class AdminReviewController
{
    public function restore(
        RestoreReviewRequest $request,
        Review $review,
        ReviewReportService $service,
    ): JsonResponse {
        $restored = $service->restore(
            $review,
            $request->user(),
            $request->validated('reason'),
        );

        return (new ReviewResource($restored))->response();
    }
}
```

> **Plan note (route-model binding for `review_report`):** The route param is `{review_report}` (snake_case for Laravel's binding), the controller arg name is `ReviewReport $reviewReport`. Laravel converts the snake_case path segment to camelCase to match. Verified by Layer 10's `AdminReturnController::resolve` shape using `OrderReturn $return` against route param `{return}`. Same convention applies here.

- [ ] **Step 1: Write the failing tests** — Resolve endpoint test file:

```php
public function test_post_keep_marks_report_resolved_and_review_stays_visible(): void
public function test_post_hide_marks_report_resolved_and_review_hidden(): void
public function test_post_hide_decrements_store_review_count(): void
public function test_post_hide_fires_review_hidden_notification(): void
public function test_post_writes_review_report_resolved_activity_log(): void
public function test_post_hide_also_writes_review_admin_hidden_activity_log(): void
public function test_post_auto_resolves_sibling_open_reports(): void
public function test_post_requires_admin_role(): void
public function test_post_on_already_resolved_report_returns_422(): void
public function test_post_requires_action_in_keep_or_hide(): void
public function test_post_requires_resolution_note(): void
public function test_post_requires_resolution_note_min_10_chars(): void
public function test_post_requires_resolution_note_max_2000_chars(): void
public function test_post_response_includes_resolved_state_action_note(): void
```

14 tests. Restore endpoint test file:

```php
public function test_post_flips_hidden_review_to_visible(): void
public function test_post_recomputes_store_aggregate_to_include_restored_review(): void
public function test_post_writes_review_admin_restored_activity_log(): void
public function test_post_on_visible_review_returns_422(): void
public function test_post_requires_admin_role(): void
public function test_post_requires_reason_min_10_chars(): void
public function test_post_does_NOT_notify_buyer(): void
public function test_post_clears_hidden_by_admin_id_hidden_at_hide_reason(): void
```

8 tests.

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

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

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

---

### Task 9b: `POST /v1/admin/reviews/{review}/hide` — direct admin hide (bypasses the report queue)

**Files:**
- Update: `api/app/Modules/Reviews/Controllers/AdminReviewController.php` (add `hide` alongside the existing `restore`)
- Create: `api/app/Modules/Reviews/Requests/HideReviewRequest.php`
- Update: `api/app/Modules/Reviews/Services/ReviewReportService.php` (add `adminHide` method — reuses the private `applyHide` helper introduced in Task 6)
- Update: `api/app/Modules/Admin/routes.php` (add the new POST route alongside `/restore`)
- Test: `api/tests/Feature/Reviews/AdminReviewHideTest.php`
- Test: `api/tests/Feature/Reviews/ReviewReportServiceAdminHideTest.php`

This endpoint is the direct counterpart to Task 9's `/restore`. The admin store-detail Reviews tab needs to hide a `state === 'visible'` review in-place without forcing the admin to self-report-then-resolve. Same audit signal: state flip + aggregate recompute + buyer notification + `review.admin_hidden` activity log. The only difference vs. the queue-resolve path is that no `ReviewReport` row is created or mutated.

HideReviewRequest:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Reviews\Requests;

use Illuminate\Foundation\Http\FormRequest;

class HideReviewRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->hasRole('admin') === true;
    }

    public function rules(): array
    {
        return [
            'reason' => ['required', 'string', 'min:10', 'max:2000'],
        ];
    }
}
```

`ReviewReportService::adminHide`:

```php
public function adminHide(Review $review, User $admin, string $reason): Review
{
    if (! $admin->hasRole('admin')) {
        abort(403, 'Only admins can hide reviews.');
    }
    if ($review->state !== ReviewState::Visible) {
        abort(422, 'Review is not currently visible.');
    }
    $reason = trim($reason);
    if (mb_strlen($reason) < 10) {
        abort(422, 'Hide reason must be at least 10 characters.');
    }
    if (mb_strlen($reason) > 2000) {
        abort(422, 'Hide reason may not exceed 2000 characters.');
    }

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

        // Reuses the helper extracted during Task 6 implementation.
        // applyHide() flips state + recomputeAggregates + ReviewHiddenNotification + activity('review.admin_hidden').
        // report_id property is omitted from the activity log on this code path (passed as null).
        $this->applyHide($review, $store, $admin, $reason, reportId: null);

        return $review->fresh();
    });
}
```

> **Plan note (open reports against the directly-hidden review stay open) — `[USER LOCKED]`:** When an admin uses this direct-hide endpoint, any existing `state = 'open'` reports against the same review are **NOT** auto-resolved. Rationale: the admin clicking Hide on the store-detail tab may be unrelated to whoever filed the reports — and the reports may carry legitimate independent signal (e.g., a separate spam complaint that a moderator should still review). Keeping the reports open in the queue preserves the audit signal that the community reported the review separately from the admin's direct action. **Contrast with the queue-resolve path:** when the admin resolves a report with `hide`, all sibling open reports DO auto-resolve with the same action — because in that flow the admin is explicitly adjudicating the report stream and a single decision should cascade. Two different intents → two different cascade behaviours, deliberately.
>
> Operationally: after a direct hide, a queue admin opening the same review's open reports sees a review already in `state = 'hidden'`. Their resolve action will still execute (hide is idempotent — see the Task 6 idempotency plan note), the report flips to `resolved` with whatever action the queue admin picks, and the activity log captures both events as separate rows. No data corruption; just a slightly noisier audit chain that reflects the two-actor reality.

AdminReviewController (extend with `hide`):

```php
public function hide(
    HideReviewRequest $request,
    Review $review,
    ReviewReportService $service,
): JsonResponse {
    $hidden = $service->adminHide(
        $review,
        $request->user(),
        $request->validated('reason'),
    );

    return (new ReviewResource($hidden))->response();
}
```

Routes — add inside the existing `auth:sanctum + admin` group at `api/app/Modules/Admin/routes.php`, immediately after the existing `/reviews/{review}/restore` line:

```php
Route::post('reviews/{review}/hide', [AdminReviewController::class, 'hide']);
```

- [ ] **Step 1: Write the failing tests** — `AdminReviewHideTest.php` (endpoint-level):

```php
public function test_post_flips_visible_review_to_hidden(): void
public function test_post_recomputes_store_aggregate_excluding_hidden_review(): void
public function test_post_fires_review_hidden_notification_to_buyer(): void
public function test_post_writes_review_admin_hidden_activity_log_without_report_id(): void
public function test_post_on_already_hidden_review_returns_422(): void
public function test_post_requires_admin_role(): void
public function test_post_requires_reason_min_10_chars(): void
public function test_post_requires_reason_max_2000_chars(): void
public function test_post_leaves_open_reports_against_this_review_unchanged(): void
public function test_post_response_includes_state_hidden_and_hidden_at(): void
```

10 tests. The `leaves_open_reports_against_this_review_unchanged` test is the explicit coverage for the locked sub-decision: seed two `state = 'open'` reports against the review, hit the hide endpoint, assert both reports remain `open` and are still listed in the admin queue endpoint (`GET /v1/admin/review-reports?state=open`).

Service unit test — `ReviewReportServiceAdminHideTest.php`:

```php
public function test_admin_hide_short_circuits_when_review_already_hidden(): void
public function test_admin_hide_throws_403_for_non_admin(): void
public function test_admin_hide_invokes_shared_apply_hide_helper(): void
```

3 tests. These cover service-layer guard rails the endpoint test doesn't exercise (the 403 path is reachable only by bypassing the FormRequest; we test it for defense-in-depth).

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

- [ ] **Step 3: Implement** — extract `applyHide` private helper from `resolve` (done during Task 6), then implement `adminHide` to call it. Update the activity-log emission inside `applyHide` to conditionally include `report_id` only when non-null.

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

---

## Phase D — Notifications

### Task 10: `ReviewReportedNotification` (admin fanout)

**Files:**
- Replace the stub from Task 5: `api/app/Modules/Notifications/Notifications/ReviewReportedNotification.php`
- Test: `api/tests/Feature/Notifications/ReviewReportedNotificationTest.php`

Mirrors `ReviewPublishedNotification`'s shape (see `api/app/Modules/Notifications/Notifications/ReviewPublishedNotification.php` for the canonical template):

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\ReviewReport;
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;

final class ReviewReportedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly ReviewReport $report) {}

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

    public function toMail(User $notifiable): MailMessage
    {
        $storeName = $this->report->review?->store?->name ?? 'a store';
        $reporterName = $this->report->reportedBy?->name ?? 'A user';
        $reasonLabel = str_replace('_', ' ', $this->report->reason->value);

        return (new MailMessage)
            ->subject('New review report')
            ->from(config('mail.from.address'), 'Alqove')
            ->line("{$reporterName} reported a review on {$storeName}.")
            ->line("Reason: {$reasonLabel}")
            ->when($this->report->reason_text !== null, fn (MailMessage $m) => $m->line("Note: {$this->report->reason_text}"))
            ->action(
                'Open the moderation queue',
                config('app.frontend_url').'/admin/reviews?focus='.$this->report->id,
            );
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        $reasonLabel = str_replace('_', ' ', $this->report->reason->value);

        return [
            'title' => 'New review report',
            'body' => sprintf('A review was reported (%s).', $reasonLabel),
            'cta_url' => '/admin/reviews?focus='.$this->report->id,
            'icon' => 'flag',
            'context_type' => 'review_report',
            'context_id' => $this->report->id,
        ];
    }
}
```

> **Plan note (icon: `flag`):** The existing icon-route map already includes a flag icon for admin-targeted reports (used by `ReturnEscalationOpenedNotification` in Layer 10 — verify the icon name used there; if it's `package-return`, use `flag` here as a distinct visual for review reports). The frontend's notification bell renders the icon name from the database notification payload.

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

```php
public function test_uses_reviews_category(): void
public function test_via_returns_mail_and_database_for_default_user(): void
public function test_via_respects_reviews_category_opt_out(): void
public function test_to_mail_subject_and_action_link(): void
public function test_to_database_payload_shape(): void
public function test_implements_should_queue(): void
```

6 tests.

- [ ] **Step 2: Run, confirm failure** — stub exists but full body absent; mail/database methods missing.

- [ ] **Step 3: Implement.** Use `Notification::fake()` + `assertSentTo` patterns matching `Tests\Feature\Notifications\ReviewPublishedNotificationTest`.

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

---

### Task 11: `ReviewHiddenNotification` (buyer)

**Files:**
- Replace the stub from Task 6: `api/app/Modules/Notifications/Notifications/ReviewHiddenNotification.php`
- Test: `api/tests/Feature/Notifications/ReviewHiddenNotificationTest.php`

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

final class ReviewHiddenNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly Review $review,
        public readonly string $reason,
    ) {}

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

    public function toMail(User $notifiable): MailMessage
    {
        $storeName = $this->review->store?->name ?? 'the seller';

        return (new MailMessage)
            ->subject('Your review has been hidden')
            ->from(config('mail.from.address'), 'Alqove')
            ->line("Your review of {$storeName} has been hidden by Alqove moderators.")
            ->line("Reason: {$this->reason}")
            ->line('If you believe this was an error, please contact support.')
            ->action(
                'View your reviews',
                config('app.frontend_url').'/me/reviews',
            );
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Your review has been hidden',
            'body' => sprintf('Reason: %s', $this->reason),
            'cta_url' => '/me/reviews',
            'icon' => 'eye-off',
            'context_type' => 'review',
            'context_id' => $this->review->id,
        ];
    }
}
```

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

```php
public function test_uses_reviews_category(): void
public function test_via_returns_mail_and_database_for_default_user(): void
public function test_to_mail_includes_reason_text(): void
public function test_to_mail_includes_support_appeal_line(): void
public function test_to_database_payload_includes_review_id(): void
public function test_implements_should_queue(): void
```

6 tests.

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

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

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

---

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

### Task 12: OpenAPI contract updates

**Files:**
- Update: `api/contracts/openapi.yaml` (add four paths + four schemas)

Add the four new paths under `paths:`:

```yaml
/v1/reviews/{review}/reports:
  post:
    summary: Report a review (any logged-in user)
    tags: [Reviews]
    security: [bearerAuth: []]
    parameters:
      - in: path
        name: review
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [reason]
            properties:
              reason:
                $ref: '#/components/schemas/ReportReason'
              reason_text:
                type: string
                nullable: true
                maxLength: 2000
    responses:
      '201':
        description: Report created
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ReviewReport' }
      '401': { description: Unauthenticated }
      '422': { description: Validation failure (invalid reason / own review / already hidden / duplicate) }

/v1/admin/review-reports:
  get:
    summary: Paginated review-report moderation queue (admin only)
    tags: [Admin Reviews]
    security: [bearerAuth: []]
    parameters:
      - in: query
        name: state
        schema:
          type: string
          enum: [open, resolved, all]
          default: open
      - in: query
        name: page
        schema: { type: integer, minimum: 1, default: 1 }
      - in: query
        name: per_page
        schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    responses:
      '200':
        description: Paginated list
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: array
                  items: { $ref: '#/components/schemas/ReviewReportSummary' }
                meta: { $ref: '#/components/schemas/PaginationMeta' }
                links: { $ref: '#/components/schemas/PaginationLinks' }
      '403': { description: Not an admin }

/v1/admin/review-reports/{review_report}/resolve:
  post:
    summary: Resolve a review report (admin only)
    tags: [Admin Reviews]
    security: [bearerAuth: []]
    parameters:
      - in: path
        name: review_report
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [action, resolution_note]
            properties:
              action:
                $ref: '#/components/schemas/ReviewAdminAction'
              resolution_note:
                type: string
                minLength: 10
                maxLength: 2000
    responses:
      '200':
        description: Report resolved
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ReviewReport' }
      '403': { description: Not an admin }
      '422': { description: Already resolved or invalid input }

/v1/admin/reviews/{review}/restore:
  post:
    summary: Restore a hidden review (admin only)
    tags: [Admin Reviews]
    security: [bearerAuth: []]
    parameters:
      - in: path
        name: review
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [reason]
            properties:
              reason:
                type: string
                minLength: 10
                maxLength: 2000
    responses:
      '200':
        description: Review restored
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/Review' }
      '403': { description: Not an admin }
      '422': { description: Review is not hidden / invalid reason }

/v1/admin/reviews/{review}/hide:
  post:
    summary: Hide a visible review directly (admin only, bypasses the report queue)
    tags: [Admin Reviews]
    security: [bearerAuth: []]
    parameters:
      - in: path
        name: review
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [reason]
            properties:
              reason:
                type: string
                minLength: 10
                maxLength: 2000
    responses:
      '200':
        description: Review hidden
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/Review' }
      '403': { description: Not an admin }
      '422': { description: Review is not currently visible / invalid reason }
```

Schemas (add to `components.schemas`):

```yaml
ReportReason:
  type: string
  enum: [inappropriate, spam, not_about_purchase, personal_info, other]

ReviewAdminAction:
  type: string
  enum: [keep, hide]

ReviewReport:
  type: object
  properties:
    id: { type: string, format: uuid }
    review_id: { type: string, format: uuid }
    reported_by_user_id: { type: string, format: uuid }
    reason: { $ref: '#/components/schemas/ReportReason' }
    reason_text: { type: string, nullable: true }
    state:
      type: string
      enum: [open, resolved]
    resolved_by_admin_id: { type: string, format: uuid, nullable: true }
    resolved_at: { type: string, format: date-time, nullable: true }
    action:
      $ref: '#/components/schemas/ReviewAdminAction'
      nullable: true
    resolution_note: { type: string, nullable: true }
    created_at: { type: string, format: date-time }
    updated_at: { type: string, format: date-time }

ReviewReportSummary:
  type: object
  description: Admin queue row — includes the eager-loaded review + reporter trim.
  properties:
    id: { type: string, format: uuid }
    state: { type: string, enum: [open, resolved] }
    reason: { $ref: '#/components/schemas/ReportReason' }
    reason_text: { type: string, nullable: true }
    action: { $ref: '#/components/schemas/ReviewAdminAction', nullable: true }
    resolution_note: { type: string, nullable: true }
    resolved_at: { type: string, format: date-time, nullable: true }
    created_at: { type: string, format: date-time }
    reported_by:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
    review:
      type: object
      properties:
        id: { type: string, format: uuid }
        state: { type: string, enum: [visible, hidden] }
        rating: { type: integer, minimum: 1, maximum: 5 }
        title: { type: string, nullable: true }
        body: { type: string }
        reviewer:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
        store:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            slug: { type: string }
        open_report_count: { type: integer, minimum: 1 }
```

Also widen the existing `Review.state` enum in the spec from `[visible]` to `[visible, hidden]`. Add `hidden_by_admin_id`, `hidden_at`, `hide_reason` to the `Review` schema (nullable). Plan 1 may have already shipped these placeholders — verify by reading the existing spec.

- [ ] **Step 1: Edit `api/contracts/openapi.yaml`** with the additions above.
- [ ] **Step 2: Sync to web** — `./bin/sync-openapi.sh` in the web repo, then `npm run build:types` to regenerate `packages/types/src/generated.ts`.
- [ ] **Step 3: Verify** `git diff packages/types/src/generated.ts` shows the four new types, no removed types.

---

### Task 13: api-client endpoint wrappers

**Files:**
- Update: `web/packages/api-client/src/endpoints/reviews.ts` (extend the existing `createReviewEndpoints` factory)
- Update: `web/packages/api-client/src/endpoints/admin.ts` (add `reviewReports` namespace)
- Test: `web/packages/api-client/src/__tests__/reviews-endpoints.test.ts` (extend, smoke-only)

Add to `reviews.ts`:

```ts
export type ReportReason =
  | 'inappropriate'
  | 'spam'
  | 'not_about_purchase'
  | 'personal_info'
  | 'other';

export type ReviewAdminAction = 'keep' | 'hide';

export interface ReviewReport {
  id: string;
  review_id: string;
  reported_by_user_id: string;
  reason: ReportReason;
  reason_text: string | null;
  state: 'open' | 'resolved';
  resolved_by_admin_id: string | null;
  resolved_at: string | null;
  action: ReviewAdminAction | null;
  resolution_note: string | null;
  created_at: string;
  updated_at: string;
}

export interface ReviewReportSummary {
  id: string;
  state: 'open' | 'resolved';
  reason: ReportReason;
  reason_text: string | null;
  action: ReviewAdminAction | null;
  resolution_note: string | null;
  resolved_at: string | null;
  created_at: string;
  reported_by: { id: string; name: string };
  review: {
    id: string;
    state: 'visible' | 'hidden';
    rating: number;
    title: string | null;
    body: string;
    reviewer: { id: string; name: string };
    store: { id: string; name: string; slug: string };
    open_report_count: number;
  };
}

export interface PaginatedReviewReportSummariesResponse {
  data: ReviewReportSummary[];
  meta: PaginationMeta;
  links: PaginationLinks;
}
```

Widen the existing `Review` shape's `state` from `'visible'` to `'visible' | 'hidden'` and add the three nullable hidden-related fields (`hidden_by_admin_id`, `hidden_at`, `hide_reason`).

Add to the factory's returned object:

```ts
reportReview: (reviewId: string, input: { reason: ReportReason; reason_text?: string | null }) =>
  client.post<{ data: ReviewReport }>(`/v1/reviews/${reviewId}/reports`, input),
```

Add to `admin.ts` a new sub-namespace:

```ts
reviewReports: {
  list: (params: { state?: 'open' | 'resolved' | 'all'; page?: number; per_page?: number } = {}) => {
    const qs = new URLSearchParams();
    if (params.state) qs.set('state', params.state);
    if (params.page) qs.set('page', String(params.page));
    if (params.per_page) qs.set('per_page', String(params.per_page));
    const suffix = qs.toString() ? `?${qs}` : '';
    return client.get<PaginatedReviewReportSummariesResponse>(`/v1/admin/review-reports${suffix}`);
  },
  resolve: (reportId: string, input: { action: ReviewAdminAction; resolution_note: string }) =>
    client.post<{ data: ReviewReport }>(`/v1/admin/review-reports/${reportId}/resolve`, input),
  restoreReview: (reviewId: string, input: { reason: string }) =>
    client.post<{ data: Review }>(`/v1/admin/reviews/${reviewId}/restore`, input),
  hideReview: (reviewId: string, input: { reason: string }) =>
    client.post<{ data: Review }>(`/v1/admin/reviews/${reviewId}/hide`, input),
},
```

- [ ] **Step 1: Update the client + types.**
- [ ] **Step 2: Build types** — `npm run build:types`.
- [ ] **Step 3: Typecheck root + web** — `npm run typecheck`.

---

## Phase F — Frontend

### Task 14: TanStack Query hooks — `useReportReview`, `useAdminReviewReports`, `useResolveReviewReport`, `useAdminRestoreReview`, `useAdminHideReview`

**Files:**
- Update: `web/src/lib/queries/use-reviews.ts` (add `useReportReview`)
- Create: `web/src/lib/queries/use-admin-review-reports.ts`
- Update: `web/src/lib/queries/use-admin.ts` (export `useAdminRestoreReview` + `useAdminHideReview` alongside `useAdminVerifyStore` etc.)
- Test: existing tests should pass; no new hook-only tests required.

```ts
// use-reviews.ts — new export
export function useReportReview(reviewId: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (input: { reason: ReportReason; reason_text?: string | null }) =>
      client.reviews.reportReview(reviewId, input),
    onSuccess: () => {
      // Optimistic invalidation of any list that might display the reported review.
      queryClient.invalidateQueries({ queryKey: ['store-reviews'] });
    },
  });
}

// use-admin-review-reports.ts — new file
export function useAdminReviewReports(params: { state?: 'open' | 'resolved' | 'all'; page?: number }) {
  return useQuery({
    queryKey: ['admin', 'review-reports', params],
    queryFn: () => client.admin.reviewReports.list(params),
  });
}

export function useResolveReviewReport(reportId: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (input: { action: 'keep' | 'hide'; resolution_note: string }) =>
      client.admin.reviewReports.resolve(reportId, input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'review-reports'] });
    },
  });
}

// use-admin.ts — new exports
export function useAdminRestoreReview(reviewId: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (reason: string) =>
      client.admin.reviewReports.restoreReview(reviewId, { reason }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'store'] });
    },
  });
}

export function useAdminHideReview(reviewId: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (reason: string) =>
      client.admin.reviewReports.hideReview(reviewId, { reason }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'store'] });
      // The open report queue is unaffected by direct hide (see Task 9b plan note),
      // but invalidating it is cheap and ensures any UI displaying open_report_count refreshes.
      queryClient.invalidateQueries({ queryKey: ['admin', 'review-reports'] });
    },
  });
}
```

- [ ] **Step 1: Implement the hooks.**
- [ ] **Step 2: Typecheck.**

---

### Task 15: `<ReportReviewButton>` + `<ReportReviewDialog>`

**Files:**
- Create: `web/src/components/reviews/report-review-button.tsx`
- Create: `web/src/components/reviews/report-review-dialog.tsx`
- Update: `web/src/components/reviews/store-reviews-list.tsx` (render the button on each row when `auth.user` is set)
- Test: `web/src/components/reviews/__tests__/report-review-dialog.test.tsx`

```tsx
'use client';

import { useState } from 'react';
import { useAuthStore } from '@/stores/auth';
import { useReportReview } from '@/lib/queries/use-reviews';

type Reason = 'inappropriate' | 'spam' | 'not_about_purchase' | 'personal_info' | 'other';
const REASON_LABELS: Record<Reason, string> = {
  inappropriate: 'Inappropriate content',
  spam: 'Spam or advertising',
  not_about_purchase: 'Not about a purchase',
  personal_info: 'Contains personal info',
  other: 'Other',
};

export function ReportReviewButton({ reviewId }: { reviewId: string }) {
  const user = useAuthStore((s) => s.user);
  const [open, setOpen] = useState(false);
  if (!user) {
    return (
      <button type="button" className="text-xs text-slate-400" title="Log in to report" disabled>
        Report
      </button>
    );
  }
  return (
    <>
      <button type="button" onClick={() => setOpen(true)} className="text-xs text-slate-500 hover:text-slate-800">
        Report
      </button>
      {open && <ReportReviewDialog reviewId={reviewId} onClose={() => setOpen(false)} />}
    </>
  );
}

export function ReportReviewDialog({ reviewId, onClose }: { reviewId: string; onClose: () => void }) {
  const [reason, setReason] = useState<Reason>('inappropriate');
  const [reasonText, setReasonText] = useState('');
  const mutation = useReportReview(reviewId);

  function submit() {
    mutation.mutate(
      { reason, reason_text: reason === 'other' ? reasonText : null },
      { onSuccess: onClose },
    );
  }

  return (
    <div role="dialog" aria-label="Report review" className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
      <div className="w-full max-w-md rounded bg-white p-5 shadow-xl">
        <h2 className="text-lg font-semibold">Report this review</h2>
        <fieldset className="mt-3 space-y-2 text-sm">
          {(Object.keys(REASON_LABELS) as Reason[]).map((r) => (
            <label key={r} className="flex items-center gap-2">
              <input type="radio" name="reason" value={r} checked={reason === r} onChange={() => setReason(r)} />
              <span>{REASON_LABELS[r]}</span>
            </label>
          ))}
        </fieldset>
        {reason === 'other' && (
          <textarea
            value={reasonText}
            onChange={(e) => setReasonText(e.target.value)}
            placeholder="Briefly describe the issue (required)"
            className="mt-3 w-full rounded border px-2 py-1 text-sm"
            rows={3}
            maxLength={2000}
          />
        )}
        {mutation.error && (
          <p className="mt-2 text-sm text-red-600">{(mutation.error as Error).message}</p>
        )}
        <div className="mt-4 flex justify-end gap-2">
          <button type="button" onClick={onClose} className="rounded border px-3 py-1 text-sm">Cancel</button>
          <button
            type="button"
            onClick={submit}
            disabled={mutation.isPending || (reason === 'other' && reasonText.trim().length < 1)}
            className="rounded bg-slate-900 px-3 py-1 text-sm text-white disabled:opacity-50"
          >
            {mutation.isPending ? 'Reporting…' : 'Report'}
          </button>
        </div>
      </div>
    </div>
  );
}
```

Also expose the button on the public item-detail page's review section (the spec mentions a review snippet on `/items/{id}`). Read `web/src/app/(buyer)/items/[id]/...` and add the button alongside each rendered review row.

- [ ] **Step 1: Write the test** — `report-review-dialog.test.tsx`: 4 cases (renders 5 radios; `reason_text` shows only when `other`; submit disabled while pending; calls `onClose` on success).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 4/4 PASS.**

---

### Task 16: Admin queue page — `/admin/reviews`

**Files:**
- Create: `web/src/app/(admin)/admin/reviews/page.tsx`
- Create: `web/src/app/(admin)/admin/reviews/admin-reviews-client.tsx`
- Create: `web/src/components/admin/admin-resolve-review-dialog.tsx`
- Update: `web/src/app/(admin)/layout.tsx` (add the `/admin/reviews` nav item)
- Test: `web/src/app/(admin)/admin/reviews/__tests__/admin-reviews-client.test.tsx`
- Test: `web/src/components/admin/__tests__/admin-resolve-review-dialog.test.tsx`

Layout nav update — insert the new nav item between Returns and Balances (alphabetical-by-purpose: dispute → inbox → store-mgmt → orders → returns → reviews → balances):

```tsx
const navItems = [
  { href: "/admin", label: "Dashboard" },
  { href: "/admin/disputes", label: "Disputes" },
  { href: "/admin/inbox", label: "Inbox" },
  { href: "/admin/stores", label: "Stores" },
  { href: "/admin/orders", label: "Orders" },
  { href: "/admin/returns", label: "Returns" },
  { href: "/admin/reviews", label: "Reviews" },   // ← new
  { href: "/admin/balances", label: "Balances" },
  { href: "/admin/payouts", label: "Failed payouts" },
  { href: "/admin/activity", label: "Activity" },
];
```

Page (`page.tsx`):

```tsx
import { AdminReviewsClient } from './admin-reviews-client';

export const dynamic = 'force-dynamic';
export default function Page() {
  return <AdminReviewsClient />;
}
```

Client (`admin-reviews-client.tsx`):

```tsx
'use client';

import { useState } from 'react';
import { useAdminReviewReports, useResolveReviewReport } from '@/lib/queries/use-admin-review-reports';
import { AdminResolveReviewDialog } from '@/components/admin/admin-resolve-review-dialog';

type StateFilter = 'open' | 'resolved' | 'all';

export function AdminReviewsClient() {
  const [state, setState] = useState<StateFilter>('open');
  const [page, setPage] = useState(1);
  const [focusReport, setFocusReport] = useState<string | null>(null);

  const { data, isLoading, isError } = useAdminReviewReports({ state, page });

  return (
    <div className="space-y-4">
      <header className="flex items-center justify-between">
        <h1 className="text-xl font-semibold">Review reports</h1>
        <nav className="flex gap-1 text-sm" role="tablist">
          {(['open', 'resolved', 'all'] as const).map((s) => (
            <button
              key={s}
              role="tab"
              aria-selected={state === s}
              onClick={() => { setState(s); setPage(1); }}
              className={`rounded px-3 py-1 ${state === s ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700'}`}
              data-testid={`state-filter-${s}`}
            >
              {s[0].toUpperCase() + s.slice(1)}
            </button>
          ))}
        </nav>
      </header>

      {isLoading && <p className="text-sm text-slate-400">Loading…</p>}
      {isError && <p className="rounded bg-red-50 p-3 text-sm text-red-700">Couldn&apos;t load reports.</p>}

      {data && (
        <>
          <table className="w-full text-sm">
            <thead>
              <tr className="text-left text-xs uppercase text-slate-500">
                <th className="py-2">Reporter</th>
                <th>Reason</th>
                <th>Review (snippet)</th>
                <th>Store</th>
                <th>State</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {data.data.map((r) => (
                <tr key={r.id} className="border-t" data-testid={`report-row-${r.id}`}>
                  <td className="py-2">{r.reported_by.name}</td>
                  <td>{r.reason.replace(/_/g, ' ')}</td>
                  <td className="max-w-xs truncate">{r.review.title ?? r.review.body.slice(0, 60)}…</td>
                  <td>{r.review.store.name}</td>
                  <td>{r.state}</td>
                  <td>
                    {r.state === 'open' ? (
                      <button onClick={() => setFocusReport(r.id)} className="rounded bg-slate-900 px-2 py-1 text-xs text-white">
                        Review
                      </button>
                    ) : (
                      <span className="text-xs text-slate-400">{r.action ?? '—'}</span>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          {/* page nav buttons truncated for brevity */}
        </>
      )}

      {focusReport && (
        <AdminResolveReviewDialog
          report={data!.data.find((r) => r.id === focusReport)!}
          onClose={() => setFocusReport(null)}
        />
      )}
    </div>
  );
}
```

`<AdminResolveReviewDialog>` — mirrors `<AdminResolveReturnDialog>`. Renders the review content + the report's reason + `reason_text`, an `action` radio (`keep` | `hide`), a `resolution_note` textarea (min 10 chars), Cancel + Submit. On success, calls `useResolveReviewReport(report.id)` and closes.

- [ ] **Step 1: Write the tests** — admin-reviews-client.test.tsx (4 cases: renders open by default, filter pills update state, paginated list renders rows, opens dialog on Review click). admin-resolve-review-dialog.test.tsx (5 cases: renders review body + reason; action radio toggles; submit disabled until note ≥10 chars; calls mutation; closes on success).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 9/9 PASS.**

---

### Task 17: Admin store-detail Reviews tab

**Files:**
- Create: `web/src/app/(admin)/admin/stores/[id]/admin-store-reviews-tab.tsx`
- Update: `web/src/app/(admin)/admin/stores/[id]/store-detail-client.tsx` (widen `Tab` union to include `'reviews'`; add nav button; render the new tab body)
- Test: `web/src/app/(admin)/admin/stores/[id]/__tests__/admin-store-reviews-tab.test.tsx`
- Test (extend): `web/src/app/(admin)/admin/stores/[id]/__tests__/store-detail-client.test.tsx` (add a "renders reviews tab when selected" case)

Tab widening:

```tsx
type Tab = 'overview' | 'ledger' | 'reviews';
```

Add a `Reviews` tab button alongside the existing `Overview` and `Ledger` buttons with `data-testid="store-detail-tab-reviews"`.

```tsx
{tab === 'reviews' && (
  <AdminStoreReviewsTab storeId={storeId} />
)}
```

`<AdminStoreReviewsTab>` body:
- State filter pills (Visible | Hidden | All)
- Paginated list of reviews for `storeId`, fetched via the admin-only endpoint added in Task 17a (Plan 1's public `GET /v1/stores/{store}/reviews` filters to visible-only — admins need the unfiltered view)
- Per-row action button conditional on row state:
  - `state === 'visible'` → **Hide** button → opens `<ConfirmWithJustificationDialog>` with a required reason textarea (min 10 chars). Submit calls `useAdminHideReview(reviewId)` → POST `/v1/admin/reviews/{review}/hide`.
  - `state === 'hidden'` → **Restore** button → opens `<ConfirmWithJustificationDialog>` with a required reason textarea (min 10 chars). Submit calls `useAdminRestoreReview(reviewId)` → POST `/v1/admin/reviews/{review}/restore`.
- Both buttons reuse the same `<ConfirmWithJustificationDialog>` component for a consistent UX; the difference is the mutation hook + the dialog title/confirm-button copy.

> **Plan note (admin store-detail list — state filter requires a new endpoint or query param) — `[USER LOCKED via decision #13]`:** Plan 1's `GET /v1/stores/{store}/reviews` is the public path and filters to `visible` only. The admin store-detail tab needs to see hidden reviews too. Two options: (a) add a `?state=visible|hidden|all` query param to the existing endpoint, gated to admin users for non-visible values; (b) add a separate admin endpoint `GET /v1/admin/stores/{store}/reviews` mirroring the public one but with the filter. **Plan 2 picks (b)** — cleaner separation; admins always go through the `/admin/` namespace. Task 17a ships `GET /v1/admin/stores/{store}/reviews` with `state` filter + 4 tests.

> **Plan note (direct admin Hide button shipped) — `[USER LOCKED via decision #14, flipped from prior plan posture]`:** Plan 2 ships `POST /v1/admin/reviews/{review}/hide` (Task 9b above) so the admin store-detail tab can hide a visible review in-place with a single justified click. Mirrors `/restore` symmetrically (admin-only + required `reason` ≥ 10 chars). Open reports against the directly-hidden review intentionally remain `state = 'open'` in the queue — see the Task 9b plan note for the rationale.

So the Reviews tab fetches from a new endpoint:

```ts
client.admin.storeReviews(storeId, { state: 'all' | 'visible' | 'hidden', page })
```

> **Task 17a — bolt-on:** Add `GET /v1/admin/stores/{store}/reviews` in `AdminStoreController` (or a new `AdminStoreReviewsController`) returning a paginated list with `?state=` filter. Resource: extend `ReviewResource`. 4 tests. Added to the test-count below.

Pseudo-shape for the action button rendering inside the per-row JSX:

```tsx
{review.state === 'visible' && (
  <button
    onClick={() => setHideTarget(review.id)}
    data-testid={`hide-review-${review.id}`}
    className="rounded bg-red-50 px-2 py-1 text-xs text-red-700 hover:bg-red-100"
  >
    Hide
  </button>
)}
{review.state === 'hidden' && (
  <button
    onClick={() => setRestoreTarget(review.id)}
    data-testid={`restore-review-${review.id}`}
    className="rounded bg-emerald-50 px-2 py-1 text-xs text-emerald-700 hover:bg-emerald-100"
  >
    Restore
  </button>
)}

{hideTarget && (
  <ConfirmWithJustificationDialog
    title="Hide this review?"
    description="The buyer will be notified that their review has been hidden. Provide a reason (min 10 chars) — it appears in the buyer's notification and in the activity log."
    confirmLabel="Hide review"
    onCancel={() => setHideTarget(null)}
    onConfirm={async (reason) => {
      await hideMutation.mutateAsync(reason);
      setHideTarget(null);
    }}
  />
)}
```

- [ ] **Step 1: Write the tests** — admin-store-reviews-tab.test.tsx (6 cases: renders paginated list; state filter toggles; hidden review shows "Hidden" badge; Restore button visible on hidden rows + opens justification dialog + submit calls `restoreReview` mutation; Hide button visible on visible rows + opens justification dialog + submit calls `hideReview` mutation; Hide button NOT rendered on already-hidden rows / Restore button NOT rendered on visible rows). Extension to `store-detail-client.test.tsx` (1 case: tab nav switches to reviews tab).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 7/7 PASS** (6 + 1).

---

### Task 18: `/me/reviews` — "Hidden by admin" marker for the buyer's own hidden reviews

**Files:**
- Update: `web/src/app/(buyer)/me/reviews/my-reviews-client.tsx`
- Test (extend): `web/src/app/(buyer)/me/reviews/__tests__/my-reviews-client.test.tsx` (add 2 cases)

Render a small `Hidden by admin` badge + the `hide_reason` text on any review row whose `state === 'hidden'`. The Edit button on that row is suppressed (the buyer can't edit a hidden review).

```tsx
{review.state === 'hidden' && (
  <div className="mt-2 rounded bg-amber-50 p-2 text-xs text-amber-800">
    <strong>Hidden by admin.</strong>
    {review.hide_reason && <p className="mt-1">{review.hide_reason}</p>}
    <p className="mt-1">If you believe this was an error, contact support.</p>
  </div>
)}
```

- [ ] **Step 1: Write the tests** (2 cases: hidden review renders marker + hide_reason; visible review does not render marker; Edit button suppressed on hidden row).
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 3/3 PASS.**

---

## Phase G — Wrap-up

### Task 19: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected delta:
  - Phase A: 3 (state Hidden) + 5 (schema) + 9 (model) = **17**
  - Phase B: 2 (writer visibility) + 13 (open) + 21 (resolve) + 10 (restore) = **46**
  - Phase C: 13 (POST report) + 9 (admin queue) + 14 (resolve endpoint) + 8 (restore endpoint) + 13 (Task 9b: 10 endpoint + 3 service for admin direct-hide) = **57**
  - Phase C bolt-on (admin store reviews — Task 17a): **4**
  - Phase D: 6 + 6 = **12**
  - Some overlap collapses (the open/resolve unit tests also exercise validation paths the endpoint tests re-assert; the adminHide service tests overlap somewhat with the endpoint tests). **Expected net: +54 to +64 new API tests** (target per spec: +54). API count: **1170 → ~1224–1234**.

- [ ] **Step 2: Backend lint** — `cd api && ./vendor/bin/pint app/Modules/Reviews app/Modules/Notifications/Notifications/ReviewReportedNotification.php app/Modules/Notifications/Notifications/ReviewHiddenNotification.php app/Models/ReviewReport.php app/Support/Enums/ReportReason.php app/Support/Enums/ReviewReportState.php app/Support/Enums/ReviewAdminAction.php app/Support/Enums/ReviewState.php app/Modules/Admin/routes.php database/migrations/2026_05_13_200001_relax_reviews_state_check.php database/migrations/2026_05_13_200002_create_review_reports_table.php database/factories/ReviewReportFactory.php tests/Feature/Reviews tests/Feature/Notifications`.

- [ ] **Step 3: Web typecheck** — `npm run typecheck` at root.

- [ ] **Step 4: Web lint** — `npm run lint`.

- [ ] **Step 5: Web tests** — `npm run test`. Expected delta:
  - Phase F: 4 (report dialog) + 9 (admin queue + resolve dialog) + 7 (admin store-detail reviews tab — was 6, +1 for the Hide-button-on-visible-row case) + 3 (me/reviews hidden marker) = **23**
  - With some overlap collapsing, **expected net: +16 to +21 new web tests** (target per spec: +16). Web count: **342 → ~360**.

- [ ] **Step 6: Local web build** — `npm run build:web`. All new components are `'use client'`; no SSR breakage expected.

- [ ] **Step 7: Brief manual-QA scenarios:**
  1. **Happy report flow** — Log in as buyer A; go to a store-detail page where buyer B has left a review; click "Report"; pick "Spam"; submit; expect a toast + the admin notification bell shows the new entry (verify via Mailpit + the admin's `/notifications` page).
  2. **Admin queue + hide** — Log in as admin; visit `/admin/reviews`; expect the new report at the top; click Review → dialog opens; pick `hide` + type a 50-char note; submit; verify (a) the review disappears from the public store page; (b) the store's `average_rating` shifts; (c) buyer B receives "Your review has been hidden" notification with the note in the body.
  3. **Admin queue + keep** — Open a second report; resolve with `keep` + note; verify the review stays visible + the report flips to `resolved` + no buyer notification fires.
  4. **Auto-resolve siblings** — Open two reports against the same review (from two different reporters); resolve the first with `hide`; verify the second auto-flips to `resolved` with action `hide` + a note referencing the primary report id.
  5. **Restore** — On the admin store-detail Reviews tab, find the hidden review from step 2; click Restore; provide a 20-char reason; submit; verify (a) the review re-appears on the public page; (b) the store aggregate recomputes; (c) NO buyer notification fires.
  6. **Direct admin Hide (new in revised Plan 2)** — On the admin store-detail Reviews tab, switch to the Visible filter; pick a visible review with NO existing reports; click Hide; provide a 20-char reason; submit; verify (a) the review state flips to `hidden`; (b) the row's action button swaps from Hide to Restore; (c) the store aggregate recomputes; (d) the buyer receives a "Your review has been hidden" notification with the reason in the body; (e) a `review.admin_hidden` activity log row appears in `/admin/activity` with no `report_id` property. Then: open a separate visible review that HAS one or more open reports against it; hit Hide directly; verify the open reports remain `open` in `/admin/reviews` (they do NOT auto-resolve via this path — unlike the queue-resolve flow).
  7. **Cannot report own review** — Log in as buyer B; navigate to the store page; the Report button on their own review either is suppressed (frontend choice) or returns 422 on click.
  8. **Cannot report hidden review** — Hidden reviews don't render on the public page anyway, so this is implicitly enforced by the read path; the 422 on the POST path is the belt-and-suspenders.

### Task 20: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app config contracts database tests docs` and commit with `feat(reviews): reports + admin moderation queue + hide/restore`.
- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit with `feat(reviews): report dialog + admin moderation queue + admin store reviews tab`.
- [ ] **Step 3:** Push both. Watch GH Actions on each — both should be green inside ~3 minutes.

---

## Architectural decisions flagged for user confirmation

Each of these is a deliberate choice that diverges from a default, expands scope, or makes an interpretation. The plan picks a default for each; flip it if you prefer the alternative.

1. **One open report per reporter per review** — application-layer 422 (not a partial unique index). See Task 5 plan note.
2. **Auto-resolve siblings independent of action** — `keep` resolves siblings as `keep`; `hide` resolves siblings as `hide`. Both paths cascade. See Task 6 plan note.
3. **Eligibility: any logged-in user can report (including admin)** — no role gate beyond `auth:sanctum`. See Task 5 plan note.
4. **Cannot report own review** — 422 at service layer. See Task 5 plan note.
5. **Cannot report a hidden review** — 422 at service layer. See Task 5 plan note.
6. **Widen `ReviewWriter::recomputeAggregates` from `private` to `public`** instead of extracting a shared `StoreReviewAggregator`. See Task 4 plan note.
7. **`ReportReason` enum cases** — `inappropriate | spam | not_about_purchase | personal_info | other`. See Task 2.
8. **`ReviewAdminAction` is a separate enum** from Layer 10's `ReturnAdminAction`. See Task 2 plan note.
9. **Postgres CHECK on `reviews.state` relaxed via DROP+CREATE** in a new migration; SQLite skipped. See Task 1.
10. **`ReviewHiddenNotification` recipient is the buyer** with the resolution note in the body + an appeal-to-support line; CTA → `/me/reviews`. See Task 11.
11. **`/me/reviews` renders hidden reviews to their author** with a "Hidden by admin" marker + the `hide_reason`. See Task 18.
12. **`/seller/reviews` does NOT show hidden reviews to sellers** — Plan 1's existing filter is correct. See Architecture section.
13. **Admin store-detail Reviews tab — state filter requires a new admin endpoint** `GET /v1/admin/stores/{store}/reviews?state=` (Task 17a, +4 tests). Scope expansion.
14. **Direct admin Hide button shipped on store-detail Reviews tab** — Plan 2 ships `POST /v1/admin/reviews/{review}/hide` (Task 9b) + a Hide button on visible rows so admin can hide a review in-place without the self-report-then-resolve detour. Mirrors `/restore` symmetrically: admin-only, required `reason` ≥ 10 chars, fires `ReviewHiddenNotification` to the buyer, writes `review.admin_hidden` activity log (no `report_id` property since no precipitating report), recomputes the store aggregate. **Sub-decision: open reports against a directly-hidden review stay `state = 'open'`** — they are NOT auto-resolved. Rationale: the admin clicking Hide on the store-detail tab may be unrelated to whoever filed the reports, and the open-report queue should still surface the community signal for separate review. (Contrast: queue-resolve `hide` DOES auto-resolve sibling reports — see decision #2.) Two different intents → two different cascade behaviours. See Task 9b + Task 17 plan notes.
15. **Restore does NOT notify the buyer** and does NOT reopen historical reports. See Task 6 plan notes.

---

## Audit discoveries (from reading the actual Plan 1 code before writing)

- `ReviewWriter::recomputeAggregates` is **`private`** at `api/app/Modules/Reviews/Services/ReviewWriter.php` line 169. The body already filters by `where('state', 'visible')` at line 171, so the recompute path is correct as-is for Plan 2; only visibility needs widening. The class is `final`, so widening to `public` carries no subclass-override risk.
- The `Review` model **already** declares `protected $casts` mapping `state => ReviewState::class` at line 70 of `api/app/Models/Review.php` AND includes `hidden_by_admin_id`, `hidden_at`, `hide_reason` in `$fillable` (lines 61–63) AND has `'hidden_at' => 'datetime'` in casts (line 76) AND a `hiddenBy()` `BelongsTo` (lines 107–110). **Plan 1 shipped these anticipating Plan 2.** Plan 2 makes NO changes to `Review.php` (only adds the `reports()` HasMany).
- The `reviews` table migration at `api/database/migrations/2026_05_13_100001_create_reviews_table.php` lines 31–33 **already** has the `hidden_by_admin_id` FK + `hidden_at` timestamp + `hide_reason` text columns nullable. The Postgres CHECK at line 48 reads `CHECK (state IN ('visible'))` — Plan 2's migration relaxes this. Plan 1 also pre-named the constraint `reviews_state_check` (line 48), matching the name Plan 2 will DROP + recreate.
- `App\Support\Enums\NotificationCategory` already includes `case Reviews = 'reviews'` at line 19 (added in Plan 1). Plan 2 reuses it; no new category.
- `SellerReviewsController` at line 37 already filters `where('state', ReviewState::Visible)` — sellers automatically stop seeing hidden reviews on `/seller/reviews` without any Plan 2 code changes.
- `PublicStoreReviewsController` at lines 25 + 39 filters `state = visible` for both `index` and `summary` — public reads automatically exclude hidden reviews.
- `BuyerReviewController::myReviews` does NOT filter by state — buyers see all their own reviews including hidden ones, which is correct for the appeal-via-support flow.
- The admin sidebar nav at `web/src/app/(admin)/layout.tsx` lines 11–19 has nine items; Plan 2 inserts a tenth (`/admin/reviews`) between Returns and Balances.
- `AdminReturnController` at `api/app/Modules/Returns/Controllers/AdminReturnController.php` is the cleanest precedent for `AdminReviewReportController` — same `index` + `resolve` shape, same eager-load + paginate + state-filter pattern.
- `web/src/components/admin/admin-resolve-return-dialog.tsx` exists and is the cleanest precedent for `<AdminResolveReviewDialog>`.
- `web/src/components/admin/confirm-with-justification-dialog.tsx` exists and is reusable for the per-row Hide / Restore actions on the admin store-detail Reviews tab (Task 17).
- `web/packages/api-client/src/endpoints/reviews.ts` already declares `state: 'visible'` in the `Review` + `ReviewSummary` interfaces (lines 22 + 40). Plan 2's Task 13 widens both to `'visible' | 'hidden'` and adds the three hidden-related nullable fields.

---

## Open items (deferred to Plan 3 or beyond)

- **Typesense field push** (`store.average_rating`, `store.review_count`, four dimension averages onto item rows) + Scout sync hooks. Plan 3.
- **Listing-card inline `★ 4.8 (142)` badge** powered by the new Typesense fields. Plan 3.
- **Activity-log row for buyer self-edit** (`review.buyer_edited` with diff in properties). Plan 3.
- **Photo attachment integration finalize** — ensure photos render correctly in the public reviews list + the report dialog shows photo thumbnails. Plan 3.
- **Bulk-hide admin endpoint** (`POST /v1/admin/reviews/bulk-hide` capped at 50 + required justification) for spam-storm scenarios. Plan 3.
- **Activity-log filter on `/admin/activity`** for review-related entries. Plan 3.
- **Seller-side polish** — dashboard widget shows dimension breakdown + per-review report-status badge (when a review was reported, seller sees the resolution publicly). Plan 3.
- ~~**Direct admin Hide button** on the store-detail Reviews tab~~ — **shipped in revised Plan 2** as Task 9b + Hide-button wiring in Task 17. Endpoint: `POST /v1/admin/reviews/{review}/hide`. Sub-decision: open reports against the directly-hidden review remain `state = 'open'` in the queue. See decision #14 above.
- **De-identification of reviewer names after account deletion** ("Buyer" / "Former buyer"). Spec line 197. Out of Plan 2 scope; relies on account-deletion flow (Layer 13+?).
- **Seller reply to reviews** — Layer 13 candidate.
- **Helpful / unhelpful votes** — Layer 13+ if it surfaces as a need.
- **Restore-notification symmetry** — currently restore is silent. If users want a "Your review has been restored" notification, small follow-up. Plan 3 candidate.
- **Re-open historical reports on restore** — currently resolved reports stay resolved even after the review is restored. Audit chain stays cleaner this way; revisit only if real workflow demand surfaces.
