Skip to main content

Review & Moderation

Implementation complete, PS-348→PS-353 (2026-05-12)

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

GateNameTriggerEffect
M-1Seller account ageSeller created within 48h of order completionReview rejected at POST with 400
M-2New buyer flagBuyer account created within 48h of reviewis_visible = false, is_suspicious = true, flagged_reason = "new_buyer"
M-2Velocity capBuyer has submitted too many reviews to the same selleris_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):

ColumnTypeNotes
iduuidPrimary key
order_iduuidUnique, one review per order
seller_iduuid
reviewer_iduuid
ratinginteger1-5
texttextMax 500 chars (PS-353)
photostext[]Photo URLs
moderation_statustextapproved (default) | published | flagged | removed
is_visiblebooleanfalse = M-2 suppressed
is_suspiciousbooleantrue = new buyer M-2 flag
flagged_reasontextnew_buyer | velocity_cap
seller_responsetextOptional seller reply
extrajsonbbuyer_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:

  • comment max 500 chars
  • rating must 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:

FieldSource
idreviews.id
reviewer_nameextra.buyer_name (via rowToKV)
seller_nameBatch-fetched from user_profiles
listing_titleextra.listing_title (populated at insert by PS-352)
statusMapped from moderation_status
flag_reasonMapped from flagged_reason
is_visiblereviews.is_visible
is_suspiciousreviews.is_suspicious
seller_responsereviews.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 sentmoderation_status writtenis_visible written
publishedpublishedtrue
flaggedflaggedtrue
removedremovedfalse

After moderation, seller score is recomputed fire-and-forget:

  • reliability_score, average_rating, total_reviews updated on user_profiles
  • Only visible reviews (is_visible !== false) counted

React admin panel

File: src/app/components/admin/ReviewModeration.tsx

FeatureDetail
Stat cardsTotal / Flagged / Removed / Suppressed (M-2) / Avg Rating
Status filterAll / Published / Flagged / Removed / Suppressed (M-2)
SUSPICIOUS badgeShown when is_suspicious === true
M-2 flag blockHuman-readable reason label in expanded detail
Visibility row"Visible to public" / "Hidden (suppressed)"
ActionsPublished → 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:

KeyIndonesian label
accuracyKesesuaian barang
communicationKomunikasi penjual
shippingKecepatan kirim
packagingKemasan

Aspects are included in the POST body only when non-empty, and stored in extra.aspects JSONB.

Where to look

What you wantWhere it lives
Review submission + M-2 detectionsupabase/functions/server/interface/http/routes-social.tsx, POST /reviews
Admin review listsupabase/functions/server/interface/http/routes-admin-extended.tsx, GET /admin/reviews
Admin moderation actionsupabase/functions/server/interface/http/routes-admin-extended.tsx, PUT /admin/reviews/:reviewId
Repositorysupabase/functions/server/infrastructure/repositories/SupabaseReviewRepository.ts
React admin panelsrc/app/components/admin/ReviewModeration.tsx
Flutter review write screenmobile/lib/screens/write_review_screen.dart
Flutter review entitymobile/lib/features/reviews/domain/entities/review_entity.dart

Next: Returns & Disputes →