Files
Auditor-Portal-Revamp/CLAUDE.md
2026-09-15 15:18:53 +05:30

19 KiB
Raw Permalink Blame History

CLAUDE.md — Auditor Portal frontend (React 19 + Vite + TS)

Guidance for AI sessions working in this app. Monorepo context + deployment live in the root CLAUDE.md; backend contracts in auditor-backend/CLAUDE.md.

Commands

  • npm run dev — Vite dev server. API base comes from .env (VITE_AUDIT_API_URL=http://localhost:7514 for local backend).
  • npm run buildtsc -b && vite builddist/. The API URL is baked in at build time from .env.production — rebuild if the backend domain changes.
  • npx tsc -b — typecheck. ⚠️ Do NOT use plain npx tsc --noEmit as the check: the root tsconfig.json is a solution-style config (files: [] + references), so --noEmit type-checks NOTHING and always passes. Only tsc -b (what npm run build runs) actually checks the app — it has caught real errors --noEmit waved through.

Architecture notes (the non-obvious ones)

Analytics (Microsoft Clarity)

index.html carries the Clarity snippet (project y7dltvy3kb), gated to window.location.hostname === 'audit-frontend.seekright.com' — dev servers, LAN-IP visits and test flows deliberately never record. If the production domain ever changes, update the gate or Clarity silently stops collecting.

API layer (src/api/)

  • axiosClient auto-attaches the logged-in user's db_name as the dbName query param on every request (multi-tenancy), rewrites /api/auditVITE_AUDIT_API_URL, and coalesces concurrent JWT refreshes (rotation-safe). Services (activityFeedsService, adminService) return the response body directly, not the axios envelope.

Filter lock-step rule (learned via chainage/sort, applies to every new filter)

Any feed filter (chainage, assets, the frequency threshold filterFrequencyGt — a free numeric "Frequency > N" input, digits-only sanitized, backed by the pipeline's occurrence marker on audits/rectification, NOT anomaly.frequency; the session metadata row shows a FREQUENCY chip, hidden when the field is absent on stale cached rows; filterHasCompleteImage — the Complete Image Any/Present/Absent select (hasCompleteImage param, whitelisted server-side); and filterAuditValues — the Features multi-select over the fixed audit-option vocabulary exported from AuditSession (ANOMALY/SAFE/RECT_SAFE_CATEGORIES + rect-true 'Others'), matched case-insensitively server-side, options follow the audits/rectification toggle; NOTE it matches nothing on un-audited pending rows — Audit_value is NULL there — so it's effectively a history/date-tab + AI-audit filter) must flow to ALL of: FeedFilters (draft + seed + clear + apply + its filterKey), GlobalFeed (destructure + the IDENTICAL filterKey + every getHistory call + effect deps), AuditSession (both session page-load calls + both deps arrays), and Dashboard (currentFilterKey + its getHistory call) — plus the backend's page AND count queries in anomalyModel (they must never drift or pagination breaks). The two filterKey templates in FeedFilters and GlobalFeed must stay character-identical or the cache guard double-fetches.

Feed store cache is a write-through cache — keep it in sync

useFeedStore caches each tab's rows (anomalies + anomaliesCopy) and the audit session is (re-)entered FROM that cache (location.state.anomalies passed by the feeds table, or activeTab.anomalies). Any mutation a session makes to a record must therefore be written to BOTH the session's local anomalies state AND the feed store tabs, or the change "disappears" on re-entry while the DB is actually fine. Existing write-throughs to mirror: applyAuditedLocally/purgePendingCaches (audit save), handleSaveAnnotations (bounding boxes — this one was learned the hard way). Match tabs per mode via tabMatchesMode in AuditSession — NOT !!t.isRectificationTab === isRectActive alone: the pending/review tabs are created with isRectificationTab: false even in rectification mode (their content follows the global isRectificationEnabled toggle), so the naive flag comparison silently skips them in rect mode while working in audits mode. Audits and rectification ids come from different tables and can collide, so a mode check is still mandatory.

Processing-failure state (processing_failed)

POST /anomaly (the audit save) and the follow-up processing call (/submit, /close, /rectified/false) are separate — when the save lands but processing fails, the record IS audited server-side. saveAuditState marks it locally audited (lock + Revert button) and tags processing_failed: true, which surfaces a Retry processing button (re-runs only the processing leg, verdict read from the record's server state; all three endpoints are idempotent, 404 = already done). Never leave such a record looking pending — that hides both the lock and the Revert escape hatch (the original #25 bug).

AuditSession (src/pages/audit-session/AuditSession.tsx) — the core screen

  • Save guards: submittingRef blocks overlapping saves; lastActionIdRef blocks back-to-back saves of the SAME record id (Enter + click both firing). Any flow that makes a locked record editable again (revert!) must clear both refs or the next save is silently swallowed until the user navigates away and back.

  • Keyboard shortcuts (handleKeyDown) fire on single letters/digits (S = Safe, A = Anomaly, …). They are paused by an explicit list of open-dialog states AND a generic guard (target is INPUT/TEXTAREA/contentEditable). Every new dialog with a text field must keep at least one of those true — the generic guard covers it, but add the dialog's open-state to the list (and to the "Shortcuts Paused" footer indicator) for visibility. ⚠️ The listener is registered in CAPTURE phase — required by the video Space/F branch, which must stopPropagation() before the focused <video controls> shadow DOM sees the key (its internal listeners toggle play/pause BEFORE any bubble handler, so the two toggles used to cancel out and Space "did nothing" after clicking the player). No other branch may stopPropagation, and the early guards return without touching the event, so dialog/text-field key handling is unaffected — keep it that way.

  • AI (auto) audits are reviewable, not locked: isAutoAudited (IsAudited=1 + auto_audit=1) records show a purple "🤖 AI Audit — needs your review" banner with the FEATURE (= Audit_value) + verdict chips, keep the verdict panel ACTIVE, and count as pending in tile states / Audit-All / staged counters. A human save overwrites the AI verdict once (server flips auto_audit→0; applyAuditedLocally mirrors that so the record then locks), and reverting that human audit restores the AI verdict. The Revert button is hidden on un-reviewed AI audits (no snapshot exists to revert to).

  • AI verdict prefill ("ghost") — DERIVED, never form state (added 2026-09-10): resolveAiSuggestion(rec, isRect) (module-level, next to computeAuditValue) maps an auto_audit=1 record's Audit_status + case-folded Audit_value onto the vocabularies → complete | partial (bare Plant / Not-an-Anomaly) | unmapped; softDeletes is computed on the EXACT string a save would write. In the component aiGhost is a useMemo-derived value shown only while isAnomaly === null && selectedCategory === null and the record is not locked/staged/loading and the Prefill toggle (isAiPrefill, localStorage 'isAiPrefill', key P, switch in the AI banner — blurred after use) is on. ⚠️ Never write the ghost into isAnomaly/selectedCategory — that would re-open the Bulk-skip (handleStageAndNext null-category → Skip), Quick-Audit auto-save, validateSubCategory and reset-path clashes the derived design avoids by construction. Accept paths: Enter accepts only in normal/Quick mode, only when the form is untouched, never on a Tab-skipped tile ("Review skipped" lands there and Enter is habitually Next), never on e.repeat (held Enter = plain Next), ignored < 250 ms after a navigation (lastIndexChangeAtRef, double-tap guard), and falls through to Next if a save is in flight or lastActionIdRef still holds the record; Y accepts everywhere incl. skipped tiles and stages in Bulk Mode (Enter in Bulk stays Next). Both go through handleAcceptAiVerdicthandleQuickSaveAndNext with explicit args (same guards/toasts; server flips auto_audit→0 like any human review), or the Bulk branch which writes stagedAudits directly (never via handleStageAndNext; no-op on a human-staged record). Blocked from one-keypress on purpose: soft-delete values, category-1-without-sub, unmapped features (banner says why). The displayed category list is NEVER switched by a Safe ghost — digits keep the null → Anomaly mapping (a Safe ghost shows on the Safe button + banner only). data-ai-ghost attributes are E2E hooks, not styling. Companion fixes in the same change: handleQuickSaveAndNext now advances only if the session is still on the saved record (currentIndexRef; a relative prev + 1 used to skip a record when the auditor navigated mid-save) and clears lastActionIdRef when the save FAILED; doRevert only wipes the form if still on the reverted record.

  • COMPLETE IMAGE view: when a record carries complete_image (pipeline-written), the MASTER pane header gains a Master|Complete segmented toggle that swaps the pane's content (header retitles to COMPLETE IMAGE; NOT a third pane — deliberate revision, the layout stays two-pane); the Complete view renders through the same ZoomableImage as the master/anomaly panes (scroll-wheel zoom toward cursor + pan while zoomed + the +/- buttons — no enlarge dialog; that was built then removed on user revision), and the swap resets to Master on every record change. ⚠️ complete_image is NOT CDN-relative like Frame_Test — the column holds BOTH shapes: the work-order layer writes an ABSOLUTE url on a different host (https://sr-img.seekright.com/seekright-ims/<uuid>.jpg — all 652 populated roadis.rectification rows), while a handful of rows carry a relative path. Always build the src through completeImageUrl() in src/constants/media.ts (passthrough when it matches ^(https?:)?//, else TEST_IMAGE_PREFIX) — never concatenate the prefix directly: the original hardcoded prefix built .../SeekRight/https://sr-img..., which the CDN 404s, so every real record silently showed the not-available fallback (fixed 2026-09-10).

  • Verdict vocabulary lives at the top of the file (ANOMALY_CATEGORIES, SAFE_CATEGORIES, RECT_SAFE_CATEGORIES, SOFT_DELETE_AUDIT_VALUES). The soft-delete list mirrors anomalyModel.update's exact strings — the UI's "low light condition" ≠ backend's "low light", so only some values actually soft-delete. Rectification TRUE is always 'Others'; rect FALSE picks 'true others' (defect NOT fixed → re-opens it) or 'false others'.

  • Revert (handleRevert/doRevert + confirmation Dialog, shown on locked records): calls POST /auditor/anomaly/revert, applies the server's restored row locally, resets itemAuditStates to 'pending', clears the save guards + verdict form, and surfaces server warnings (Anton archive / work orders are warn-only) as toasts. The admin History tab (src/pages/admin/AuditHistory.tsx) has the same flow with its own Dialog.

  • Bulk revert exists in two places, both via activityFeedsService.revertRecords (batch form of /revert; per-record results): (1) the feeds table (AnomalyTable.tsx) shows a checkbox column on history/date tabs only — select rows → "Revert Selected"; after success it drops reverted ids from the tab and invalidates the matching pending tab's lastFetchedFilterKey so they reappear as pending on next visit; (2) the admin console's dedicated Bulk Revert tab (src/pages/admin/BulkRevert.tsx) — self-contained filters (org, type, Audited_on date presets, auditor from adminService.getUsers(), site) over GET /admin/audit-history/revertable, with select-all + paginated revert.

  • Bulk Mode stages verdicts in memory (stagedAudits) and submits one POST /anomaly batch + one /submit-or-/close per site; locked phase-1 results are the retry path, mirroring the single-record 409 contract.

Annotations (AnnotationOverlay.tsx)

Image bounding boxes, format { "<frame>": { "<Label>": [["<trackId>",[x1,y1],[x2,y2]], ...] } } in 4K pixel coords, stored in the record's annotations JSONB. VIEW mode zooms/pans; EDIT mode draws. Finishing a drag opens the label-picker dialog (searchable list fed by the labelOptions prop — AuditSession passes the flattened asset names from GET /Master/asset_types, the same source as the Edit Asset dialog; free text allowed; Enter = first match, Esc = discard box). The dialog reports open/close via onLabelDialogToggle so the session pauses hotkeys. Saves go through onSavehandleSaveAnnotations (optimistic local + feed-cache write-through + fire-and-forget API). IMAGE boxes are editable; VIDEO boxes are view-only (product decision — the video file is the single source of truth, nothing video-related is persisted per row). VideoAnnotationOverlay reads the boxes live from the backend per view and supports BOTH embed generations: the new anomaly_annotations keys/ilst embeds (which declare their coordinate space via meta {width,height} — 1280×720 and 3840×1080 clips both exist) and the legacy ©cmt/annotations embeds. The overlay scales boxes by meta when present, else the video's intrinsic size, and shows a "no boxes in this video" badge when a file carries no embed (they exist). Video fullscreen goes through the session's own ⛶ button (or F), never the native control — the native fullscreen button fullscreens the bare <video>, which excludes the sibling annotation canvas, and an exit-then-retarget swap is impossible: the click's transient user activation is consumed by the native request, so the follow-up container requestFullscreen is denied and the video pops back out. Hence the native button is CSS-hidden (& video::-webkit-media-controls-fullscreen-button on the media Box) and ⛶/F fullscreen mediaBoxRef (video + canvas + inspector together); a fullscreenchange handler exits any remaining native video-fullscreen entry path (e.g. double-click) rather than letting the user watch fullscreen with silently missing boxes. Playing vs paused draw modes differ deliberately: while playing, a frame with no boxes borrows from the nearest annotated frame within ±3 (anti-strobe); while PAUSED the overlay is strict — the EXACT frame's boxes only, never borrowed/stale (user requirement: a paused frame must show truth, not interpolation). Pausing also enters inspection mode: each box gets an on-canvas stamp of its raw embedded coords (#trackId (x1,y1)→(x2,y2), annotation space not screen px) and a top-right inspector panel lists the frame number, coordinate space, fps, and every box's label/track/coords/size — or, when the exact frame has none, the nearest annotated frame number+timestamp so the user can seek to it. The whole inspection display (panel + stamps, NOT the strict drawing) sits behind the "ⓘ frame info" toggle chip on the video (localStorage 'videoFrameInfo', default on).

Live Activity tab (src/pages/admin/LiveActivity.tsx)

Replaced the Login History tab (that component still exists and renders as the "Login Sessions" sub-view). Stock-ticker feed over GET /admin/activity-feed: initial page without since, then a 10s setInterval poll passing the returned cursor — only NEW events cross the wire; fresh rows get a fading highlight. Polling runs only while the tab is mounted (AdminConsole conditionally renders tabs, so switching away stops it automatically). Type filter is server-side (types param, whitelisted), org filter uses orgDb (NOT dbName — the interceptor owns that param), user filter uses userId (dropdown from adminService.getUsers()). If you add a new event type: emit it backend-side via helpers/activityEvents.js, add it to the controller whitelist, and give it a TYPE_STYLE entry here.

Coordinates & maps

Lat/long is clickable wherever it appears (feeds LAT/LONG cell, session coordinate chip, master-carousel caption) → opens src/components/common/MapDialog.tsx: a keyless Google Maps EMBED iframe in a dialog, pinned at the FULL-precision coordinates (display rounding — 2 decimals in the feed, 5 in the session — is cosmetic only; always parse Pos fresh for map links). The master carousel's caption (CH + coords per master frame) comes from masterMeta, parsed out of Extra_Master_Image's mangled tuple blob ('fname.jpeg','N…E…',chainage) — the pipeline injects junk folder prefixes between fields, the regex spans them. Session hotkeys: Shift+C focuses the NOTES box (Esc blurs back); plain C/V cycle the master carousel and must keep ignoring shifted presses. When the anomaly carousel is on the VIDEO item, Space = play/pause and F = fullscreen toggle (both preventDefault so the focused <video controls> doesn't double-toggle); the leave-session Esc is guarded by document.fullscreenElement so exiting fullscreen never ALSO exits the session. The Annotations switch defaults ON (localStorage 'annotateMode' remembers an explicit off).

Auth & password UI — there is NO code/invite flow (retired same-day; don't rebuild it)

Login is username+password only. The header avatar menu has Change password (current/new/confirm → POST /account/change-password, sending the caller's own refresh_token so the server evicts every OTHER session). ⚠️ axiosClient exempts /account/change-password (like /account/login) from the global-401 refresh/force-logout dance — its 401 is semantic ("wrong current password"), and removing the exemption force-logs users out on a typo. Admin Users tab: RocketChat username is mandatory on Add User (credentials are DM'd on create; rocketchatSent drives the toast), editable but never clearable from Edit (blank → field omitted). The row action is just Edit; inside the Edit dialog live Delete user (confirm dialog; permanent, vs the reversible Deactivate switch) and Reset password (forgot-password rescue: fresh TEMP PASSWORD — not a code — sessions evicted, DM'd when a handle is stored and revealed to the admin ONLY when it couldn't be DM'd; both hidden for your own account). MUI is v9: Dialog styling goes through slotProps={{ paper: ... }}PaperProps no longer exists and is a type error.

Dialogs over browser popups

No window.confirm/window.prompt/alert in new code — use the MUI Dialog pattern (dark theme: bgcolor '#0f172a', border #1e293b; see the Edit Asset / Revert / label-picker dialogs for the house style).

Working guidelines

  • Verify against the code before stating behavior; this app has several places where the obvious guess is wrong (cache re-entry paths, soft-delete string mismatch, verdict vocabularies).
  • After edits run npx tsc -b (NOT --noEmit — see Commands); the Vite dev server hot-applies changes to the tester's browser.