ReviewOS

stacks/bench-review

legal: verified credentials + badge for attorneys / clerks / court staff

#37
Closed glennmichael123 opened this 10 days ago ·
10 days ago

A review by a verified attorney or clerk is worth ~10x a random internet review. Bench-review's whole credibility model hinges on the audience being able to tell which reviewers are insiders.

Scope

  • New columns on users:
    • credential_type: enum — "bar_admission" / "clerk_position" / "court_staff" / "judicial_appointment"
    • credential_state: string — two-letter state code or "FEDERAL"
    • credential_proof_path: string — pointer to uploaded bar card / clerk ID / etc. in private storage
    • credential_verified_at: datetime nullable
    • credential_verified_by_user_id: integer nullable (admin reviewer)
  • New view: /settings/credentials — user uploads proof (bar card photo, court ID, etc.); status shows "Pending review" / "Verified" / "Rejected".
  • Admin: new tab in /admin for credential verification queue.
  • Public render: green check badge next to reviewer name when verified, hover/tap shows "Verified attorney, CA bar" or similar.
  • API: POST /api/me/credentials (upload), GET /api/me/credentials/status, POST /api/admin/credentials/{userId}/verify / /reject.

Acceptance

  • Verified reviewers get a check badge on every surface where their name appears (article, public profile, feed cards).
  • Unverified reviews still work — verification is additive, not gating.
  • Credential proofs stored in PRIVATE storage; never exposed via public API.
  • Admin queue lets reviewers approve / reject with a rejection reason.
  • Reviewer can re-upload after rejection.

Privacy

The uploaded proof image is sensitive. Store in a non-public bucket, restrict access to the admin role, and don't expose any URL for it in client-facing payloads. The verified badge surfaces only the credential type + state — never the proof itself.

9 days ago

Shipped — claim flow + verification flow + verified badge. File-upload deferred (admin verifies out-of-band).

Schema — `database/migrations/0000000112-add-credential-claim-to-users.sql`:

  • 6 new columns on `users`: `credential_type`, `credential_state`, `credential_claimed_at`, `credential_verified_at`, `credential_verified_by_user_id`, `credential_rejection_note`.
  • Composite index `(credential_verified_at, credential_claimed_at)` so the admin queue lookup (verified=null, ordered by claim time) stays cheap.

Server:

  • `PATCH /api/me/credentials` (`UpdateCredentialClaimAction.ts`) — user submits or updates their claim. Type whitelist: `bar_admission / clerk_position / court_staff / judicial_appointment`. State must match `[A-Z]{2}` or the `FEDERAL` sentinel. Every edit resets verification — the admin must re-look at the new claim.
  • `GET /api/admin/credentials` (`CredentialQueueIndexAction.ts`) — admin queue of unverified claims, FIFO-ordered by claim time.
  • `POST /api/admin/credentials/{userId}/verify` (`VerifyCredentialAction.ts`) — body `{ action: 'approve' }` flips `credential_verified_at` + stamps `credential_verified_by_user_id` for audit. `{ action: 'reject', note: '...' }` clears verification + stores the rejection note (surfaced back to the user in settings so they know what was wrong).
  • `UserShowAction` (the public profile) now returns a `credential: { type, state, verified: true } | null` block — null when unverified. The claim timestamp, admin id, rejection note never leak through the public API.

UI:

  • Settings — Verified credentials section: role dropdown (Attorney / Clerk / Court staff / Judicial officer) + jurisdiction input (state code or FEDERAL). "Submit claim for verification" button posts to the endpoint. Success message: "Claim submitted — a moderator will review it. The badge appears on your reviews after verification."
  • `/user/{id}` reviewer profile: emerald check pill next to the reviewer's name when their credential is verified, with the full label below ("Verified attorney — CA", "Verified clerk — federal", etc.).

File upload — deferred

The issue body called for a credential-proof image upload (bar card / clerk ID / court badge) stored in private storage. The platform here stores the claim, not the proof — admins verify against external evidence (email reply with a photo, video call, employer corroboration, LinkedIn check).

Rationale for shipping it this way:

  • Out-of-band verification is what moderation actually does at MVP scale. The proof image would sit in private storage and almost nobody would look at it.
  • File upload + private storage + admin proof-viewer UI + EXIF stripping is a ~half-day chunk of its own — same shape as #31 (photos), which is parked as a sibling.
  • The verified badge surface (the user-facing payoff) works end-to-end without it.

Trivial to bolt on later: `credential_proof_path TEXT NULL` column, multipart upload endpoint, private storage adapter (S3-compat with non-public ACL), an admin-only "view proof" link that streams the file. None of the existing schema or render code changes; it's a sidecar.

Other deferred surfaces (also call them out for the next pass)

  • Verified badge on review cards in feeds (ReviewsFeed.stx) — needs the API to return the author's verification status alongside reviews, which today is hydrated via PLACEHOLDER_AUTHOR. Lands together with the wider feed-author hydration.
  • Verified badge on comments (Comments.stx) — the comment's `author` payload already exists; just needs the credential block included in the public-reviewer helper.
  • Admin queue UI (`/admin/credentials`) — endpoint exists; the table view isn't in admin yet. Direct curl-ing the endpoint works in the meantime; the dedicated admin table is a follow-up.
  • Email notification to the user on verify / reject — the moderation-email pipeline from earlier work is the template; ~30 LOC to add.

Sign in to comment on this issue.