Hackorda Docs
Flows

F 09 Triage

Status: 🟢 ready to test Owner: Adilet Last updated: 2026-05-08 Last verified on production:

Goal

An admin or cycle lead works through filed issues — one card at a time, across cycles or per-cycle — and decides each one: approve (commits a payout), reject (no payout), request info (parking lot, pings the reporter), or reclassify severity inline (recomputes payout amount). The reporter is notified of every decision.

Actors

  • Primary — Admin (cross-cycle queue) or Cycle lead (per-cycle within their cycle)
  • Secondary — Reporter (notified on decision); leads + admins of the cycle (notified on reclassify, info-provided)

Preconditions

  • At least one issue exists with payout_status='pending' and status='open' (F-08)
  • Caller is admin or cycle lead

Trigger

Admin opens /app/admin/triage (cross-cycle queue). Or any admin/lead opens an individual issue detail page.

Happy path — triage queue overview

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Admin/app/admin/triagePage loadsGET /api/admin/triage
2Adminqueue stripSees N issues, current card highlighted

The queue groups issues by cycle and feeds them one card at a time. On mobile (#175), the card opens as a fullscreen overlay.

Happy path — Approve

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Admintriage cardClick "Approve"POST /api/admin/triage/decide body { issueId, decision: 'approve' }UPDATE issues SET status='triaged', payout_status='approved', payout_decided_by, payout_decided_at = NOW()ISSUE_APPROVED notification to reporterissue.approved
2Frontendqueue advances to next card

Happy path — Reject

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Admintriage cardClick "Reject" → optional reason in dialogPOST /api/admin/triage/decide body { issueId, decision: 'reject', reason }UPDATE issues SET status='rejected', payout_status='rejected', payout_decided_by, payout_decided_at = NOW()ISSUE_REJECTED notificationissue.rejected
2Frontendqueue advances

Happy path — Request info (parking lot)

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1AdminTriageCommentsPanelType clarification question, click "Request info"POST /api/admin/triage/decide body { issueId, decision: 'request_info', comment }(a) INSERT INTO issue_comments, (b) UPDATE issues SET payout_status='info_requested'ISSUE_INFO_REQUESTED notification to reporterissue.info_requested
2Frontendqueue removes the card (parked)
3Reporter (later)issue detail comments panelReplyPOST /api/test-cycles/:id/issues/:id/comments(a) INSERT INTO issue_comments, (b) auto-flip: UPDATE issues SET payout_status='pending'ISSUE_INFO_PROVIDED notification to leads+adminsissue.info_provided
4Admintriage queueCard re-enters queue automatically

Happy path — Reclassify severity inline

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Adminseverity dropdown on triage cardPick a different severityPATCH /api/test-cycles/:id/issues/:issueId body { severity, payoutAmountCents }UPDATE issues SET severity = …, payout_amount_cents = …ISSUE_RECLASSIFIED notification to reporterissue.reclassified
2Frontendcard re-renders w/ new amount

Happy path — Skip / navigate

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Admin"Skip" / Prev / Next / j/k keysMove queue cursor
2Frontendnew card rendered

Acceptance criteria

  • AC-1 — Given a pending issue, when admin clicks Approve, then payout_status flips to approved and the reporter gets a bell ping.
  • AC-2 — Given an approved issue, when the gate is active and status is not verified, then the "Mark paid" CTA is disabled with a hint explaining why (see F-10).
  • AC-3 — Given an admin clicks Reject, then payout_status='rejected' AND status='rejected'. Pending payouts cannot be silently overwritten; paid issues require explicit void.
  • AC-4 — Given an admin requests info, then a comment is posted, the issue moves to info_requested, and the reporter is notified.
  • AC-5 — Given the reporter replies on an info_requested issue, then the comment POST automatically flips payout_status back to pending and notifies leads + admins.
  • AC-6 — Given a severity reclassification, then payout_amount_cents is recomputed from the cycle's effective rates (effectiveRatesFor) for the new severity — same source as the file-issue path.
  • AC-7 — Given the admin opens an issue detail and presses j / k, then the page navigates to next / previous issue in the cycle's list (preserving from=… query so back-nav restores list scroll).
  • AC-8 — Given the admin is on mobile, when they open the triage queue, then a card opens as a fullscreen overlay with Prev/Next icon buttons in the top strip and the action bar pinned to the bottom.
  • AC-9 — Given a non-admin/non-lead caller, when they call POST /api/admin/triage/decide, then 403.
  • AC-10 — Given the issue is closed-negative (duplicate / cant_reproduce / wont_fix), then any pending or approved payout auto-rejects via payoutSideEffectForStatusChange. Paid payouts are NOT auto-flipped (require explicit void).

Test data / fixtures

  • Baseline seed
  • 3+ pending issues with different severities, including at least one with attached screenshots, to exercise the lightbox

Negative paths

#ScenarioExpected behavior
N-1Approve an already-paid issueAPI 400 "invalid transition" (allowed transitions: paid → void only)
N-2Request info without a commentAPI 400 "comment required"
N-3Reclassify on a paid issueUI hides severity dropdown OR API 400
N-4Two admins approve same issue concurrentlyLast write wins; second insert into issue_payouts would fail (unique-ish), notifications dedupe
N-5Reporter replies on an issue that was already approved (not info_requested)Comment posted; no payout state flip

Manual QA checklist

  • Open /app/admin/triage → see a queue with N issues
  • Verify the screenshots embedded in the issue render inline (not as plain links)
  • Click an image → opens fullscreen lightbox (media-lightbox)
  • Click "Skip" → next card; queue advances
  • Click "Approve" on a card → toast, queue advances; reload → that issue no longer in pending list
  • Sign in as the reporter → bell shows "Issue approved"
  • Pick a high-severity issue, change severity inline to low → payout amount updates from 1000→250 KZT (cents 100000 → 25000)
  • Click "Reject" with reason on another issue → status=rejected, reporter sees "Issue rejected"
  • On a third issue, click "Request info", type "Can you share console output?" → comment posted, issue moves out of queue
  • Sign in as reporter → see comment thread, reply with a screenshot → issue re-enters admin queue
  • On phone: open /app/admin/triage → fullscreen overlay, Prev/Next icons visible top, action bar visible bottom

Automated test outline

test.describe("F-09 triage", () => {
  test("approve → reporter notified", async ({ page, request }) => { … });
  test("reject preserves audit trail", async ({ request }) => { … });
  test("request info parks issue + auto-requeue on reply", async ({ request }) => { … });
  test("reclassify severity recomputes amount", async ({ request }) => { … });
  test("paid → void only", async ({ request }) => { … });
});

Code references

  • Pages: src/app/app/admin/triage/page.tsx, src/app/app/test-cycles/[id]/issues/[issueId]/page.tsx
  • UI: src/components/test-cycles/admin/TriageQueue.tsx, src/components/test-cycles/admin/IssueTriageRow.tsx, src/components/test-cycles/admin/TriageCommentsPanel.tsx, src/components/test-cycles/MediaLightbox.tsx, src/components/test-cycles/IssueSection.tsx, src/components/test-cycles/ApprovalPayoutCard.tsx
  • API:
    • src/app/api/admin/triage/route.ts (GET — queue feed w/ attachments + presigned URLs)
    • src/app/api/admin/triage/decide/route.ts (POST — approve / reject / request_info)
    • src/app/api/test-cycles/[id]/issues/[issueId]/route.ts (PATCH — reclassify)
    • src/app/api/test-cycles/[id]/issues/[issueId]/comments/route.ts (POST — auto re-queue logic)
  • Hooks: src/hooks/test-cycles/triage.ts, src/hooks/test-cycles/issues.ts
  • Lib: src/lib/issue-payout.ts (payoutSideEffectForStatusChange, ALLOWED_PAYOUT_TRANSITIONS, effectiveRatesFor), src/lib/notifications.ts
  • Schema: issues, issue_comments, notifications, PAYOUT_STATUSES, NOTIFICATION_TYPES

Events emitted (proposed)

  • issue.approved{ issue_id, cycle_id, decided_by, severity, payout_amount_cents }
  • issue.rejected{ issue_id, cycle_id, decided_by, reason }
  • issue.info_requested{ issue_id, cycle_id, decided_by, comment_id }
  • issue.info_provided{ issue_id, comment_id, replier_user_id } (auto re-queue)
  • issue.reclassified{ issue_id, from_severity, to_severity, from_amount, to_amount, changed_by }

Open questions / known gaps

  • No "bulk approve" — every decision is per-issue (intentional for v1).
  • Triage queue scope = ADMIN only currently. Cycle leads triage via the per-issue detail page, not the cross-cycle /app/admin/triage. Confirm this is intentional.
  • No "undo last decision" within 5s.
  • Lightbox only handles images + video, not PDFs (verify on prod).

On this page