Review & Moderation
All six review moderation gaps identified in the audit are closed. M-2 fraud detection is fully surfaced to admins; aspect ratings are wired through the full Flutter stack; the admin panel correctly maps moderation_status and flagged_reason from the DB.
Architecture overview
Reviews live in the reviews table. A single order can have at most one review (upsert key: order_id). The flow has three layers:
Buyer submits review
→ POST /reviews (routes-social.tsx)
→ M-1 check: seller account age < 48h → reject 400
→ M-2 check: new_buyer flag, velocity cap
→ is_visible = false (suppressed from public feed)
→ flagged_reason = "new_buyer" | "velocity_cap"
→ upsert into reviews table
→ seller score recomputed
Admin moderates
→ GET /admin/reviews
→ batch-fetch seller names from user_profiles
→ maps moderation_status → status, flagged_reason → flag_reason
→ PUT /admin/reviews/:reviewId { status }
→ moderateById() — targeted UPDATE by primary key
→ seller score fire-and-forget recompute
M-1 and M-2 fraud detection
| Gate | Name | Trigger | Effect |
|---|---|---|---|
| M-1 | Seller account age | Seller created within 48h of order completion | Review rejected at POST with 400 |
| M-2 | New buyer flag | Buyer account created within 48h of review | is_visible = false, is_suspicious = true, flagged_reason = "new_buyer" |
| M-2 | Velocity cap | Buyer has submitted too many reviews to the same seller | is_visible = false, flagged_reason = "velocity_cap" |
M-2 suppressed reviews are stored and visible to admins, but hidden from the public seller profile feed.
Database columns
All live on the reviews table (migration 20260425000006_review_velocity.sql):
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
order_id | uuid | Unique, one review per order |
seller_id | uuid | |
reviewer_id | uuid | |
rating | integer | 1-5 |
text | text | Max 500 chars (PS-353) |
photos | text[] | Photo URLs |
moderation_status | text | approved (default) | published | flagged | removed |
is_visible | boolean | false = M-2 suppressed |
is_suspicious | boolean | true = new buyer M-2 flag |
flagged_reason | text | new_buyer | velocity_cap |
seller_response | text | Optional seller reply |
extra | jsonb | buyer_name, buyer_avatar, listing_id, listing_title, aspects, comment |
The extra JSONB is merged with real columns via rowToKV(), real columns always win on conflicts.
POST /reviews, submission
File: supabase/functions/server/interface/http/routes-social.tsx
Key validations:
commentmax 500 charsratingmust be 1-5- Order must be
COMPLETED - Only the buyer of that order can submit
The extra JSONB payload stored alongside the review:
{
"buyer_name": "...",
"buyer_avatar": "...",
"listing_id": "...",
"listing_title": "...",
"aspects": { "accuracy": 5, "communication": 4, "shipping": 5, "packaging": 4 },
"comment": "..."
}
Admin review list, GET /admin/reviews
File: supabase/functions/server/interface/http/routes-admin-extended.tsx
Response shape per review:
| Field | Source |
|---|---|
id | reviews.id |
reviewer_name | extra.buyer_name (via rowToKV) |
seller_name | Batch-fetched from user_profiles |
listing_title | extra.listing_title (populated at insert by PS-352) |
status | Mapped from moderation_status |
flag_reason | Mapped from flagged_reason |
is_visible | reviews.is_visible |
is_suspicious | reviews.is_suspicious |
seller_response | reviews.seller_response |
Admin moderation, PUT /admin/reviews/:reviewId
File: supabase/functions/server/interface/http/routes-admin-extended.tsx
Accepts: { status: "published" | "flagged" | "removed" }
Status mapping applied before writing to DB:
| Status sent | moderation_status written | is_visible written |
|---|---|---|
published | published | true |
flagged | flagged | true |
removed | removed | false |
After moderation, seller score is recomputed fire-and-forget:
reliability_score,average_rating,total_reviewsupdated onuser_profiles- Only visible reviews (
is_visible !== false) counted
React admin panel
File: src/app/components/admin/ReviewModeration.tsx
| Feature | Detail |
|---|---|
| Stat cards | Total / Flagged / Removed / Suppressed (M-2) / Avg Rating |
| Status filter | All / Published / Flagged / Removed / Suppressed (M-2) |
| SUSPICIOUS badge | Shown when is_suspicious === true |
| M-2 flag block | Human-readable reason label in expanded detail |
| Visibility row | "Visible to public" / "Hidden (suppressed)" |
| Actions | Published → Flag + Remove; Flagged → Approve + Remove; Removed → no actions |
Flutter, aspect ratings
File: mobile/lib/screens/write_review_screen.dart
Four aspect sub-rating rows appear below the overall star rating, each independently tappable, defaulting to 5 stars:
| Key | Indonesian label |
|---|---|
accuracy | Kesesuaian barang |
communication | Komunikasi penjual |
shipping | Kecepatan kirim |
packaging | Kemasan |
Aspects are included in the POST body only when non-empty, and stored in extra.aspects JSONB.
Where to look
| What you want | Where it lives |
|---|---|
| Review submission + M-2 detection | supabase/functions/server/interface/http/routes-social.tsx, POST /reviews |
| Admin review list | supabase/functions/server/interface/http/routes-admin-extended.tsx, GET /admin/reviews |
| Admin moderation action | supabase/functions/server/interface/http/routes-admin-extended.tsx, PUT /admin/reviews/:reviewId |
| Repository | supabase/functions/server/infrastructure/repositories/SupabaseReviewRepository.ts |
| React admin panel | src/app/components/admin/ReviewModeration.tsx |
| Flutter review write screen | mobile/lib/screens/write_review_screen.dart |
| Flutter review entity | mobile/lib/features/reviews/domain/entities/review_entity.dart |
Next: Returns & Disputes →