Conversation
Public unauthenticated case status lookup at h.omi.me/case/{ref}.
Self-contained server component — same design as web/app version.
Shows stage, dates, message, stale case banner, support email.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a The implementation is broadly correct and consistent with existing patterns in the codebase. A few things worth addressing:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Browser
participant h.omi.me (Next.js SSR)
participant api.omi.me
Browser->>h.omi.me (Next.js SSR): GET /case/FU-XXXXXX
h.omi.me (Next.js SSR)->>h.omi.me (Next.js SSR): Validate ref format (regex)
alt Invalid format
h.omi.me (Next.js SSR)-->>Browser: 404 Not Found
else Valid format
h.omi.me (Next.js SSR)->>api.omi.me: GET /v1/fair-use/case/{ref}/status (no-store)
alt 404
api.omi.me-->>h.omi.me (Next.js SSR): 404
h.omi.me (Next.js SSR)-->>Browser: "Case Not Found" UI
else Non-OK (5xx / network error)
api.omi.me-->>h.omi.me (Next.js SSR): Error
h.omi.me (Next.js SSR)-->>Browser: "Case Not Found" UI (same as 404 — UX gap)
else 200 OK
api.omi.me-->>h.omi.me (Next.js SSR): CaseStatus JSON
h.omi.me (Next.js SSR)-->>Browser: Case Status Card (stage, dates, message)
end
end
Last reviewed commit: "Add case status page..." |
| if (!status) { | ||
| return ( | ||
| <div className="min-h-screen bg-zinc-950 flex items-center justify-center p-6"> | ||
| <div className="max-w-md w-full text-center"> | ||
| <h1 className="text-lg font-semibold text-white mb-2">Case Not Found</h1> | ||
| <p className="text-sm text-zinc-400 mb-1"> | ||
| No case found for reference <span className="font-mono text-zinc-300">{ref}</span> | ||
| </p> | ||
| <p className="text-xs text-zinc-500 mt-4"> | ||
| If you believe this is an error, contact{' '} | ||
| <a href={`mailto:${SUPPORT_EMAIL}`} className="text-purple-400 hover:text-purple-300"> | ||
| {SUPPORT_EMAIL} | ||
| </a> | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
API errors indistinguishable from "not found"
getCaseStatus returns null for both a genuine 404 and any other non-OK response (e.g. 500, network timeout). Both paths render the "Case Not Found" UI, which could mislead a user whose case exists but whose lookup failed due to a transient API error.
Consider differentiating the two states, for example by returning a sentinel value or a typed error object for non-404 failures:
async function getCaseStatus(ref: string): Promise<CaseStatus | null | 'error'> {
try {
const res = await fetch(`${API_BASE_URL}/v1/fair-use/case/${encodeURIComponent(ref)}/status`, {
cache: 'no-store',
});
if (res.status === 404) return null;
if (!res.ok) return 'error';
return await res.json();
} catch {
return 'error';
}
}
Then render a distinct "Something went wrong, please try again" UI when the result is 'error'.
| import { notFound } from 'next/navigation'; | ||
| import { cn } from '@/lib/utils'; | ||
|
|
||
| const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.omi.me'; | ||
| const SUPPORT_EMAIL = 'team@basedhardware.com'; |
There was a problem hiding this comment.
Missing
generateMetadata export
Every other page in web/frontend that warrants its own title (e.g. unlimited/page.tsx) exports a metadata or generateMetadata export. Without one here the page falls back to the root default "Omi". A page-specific title ("Case Status | Omi") would improve SEO and the browser tab UX for users checking their case.
export async function generateMetadata({ params }: { params: Promise<{ ref: string }> }) {
const { ref } = await params;
return {
title: `Case ${ref} | Omi`,
description: 'View the status of your Omi fair-use case.',
};
}
| const STAGE_META: Record<string, { label: string; dot: string; text: string; bg: string }> = { | ||
| none: { label: 'Normal', dot: 'bg-green-400', text: 'text-green-400', bg: 'bg-green-500/[0.06]' }, | ||
| warning: { label: 'Warning', dot: 'bg-amber-400', text: 'text-amber-400', bg: 'bg-amber-500/[0.06]' }, | ||
| throttle: { label: 'Throttled', dot: 'bg-orange-400', text: 'text-orange-400', bg: 'bg-orange-500/[0.06]' }, | ||
| restrict: { label: 'Restricted', dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/[0.06]' }, | ||
| }; | ||
|
|
||
| interface CaseStatus { | ||
| case_ref: string; | ||
| stage: 'none' | 'warning' | 'throttle' | 'restrict'; | ||
| message: string; | ||
| created_at: string; | ||
| updated_at: string; | ||
| support_email?: string; | ||
| } | ||
|
|
||
| function formatDate(iso: string): string { | ||
| try { | ||
| return new Date(iso).toLocaleDateString('en-US', { | ||
| year: 'numeric', | ||
| month: 'short', | ||
| day: 'numeric', | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| }); | ||
| } catch { | ||
| return iso; | ||
| } | ||
| } | ||
|
|
||
| function daysSince(iso: string): number { | ||
| try { | ||
| return Math.floor((Date.now() - new Date(iso).getTime()) / (1000 * 60 * 60 * 24)); | ||
| } catch { | ||
| return 0; | ||
| } | ||
| } | ||
|
|
||
| async function getCaseStatus(ref: string): Promise<CaseStatus | null> { | ||
| try { | ||
| const res = await fetch(`${API_BASE_URL}/v1/fair-use/case/${encodeURIComponent(ref)}/status`, { | ||
| cache: 'no-store', | ||
| }); | ||
| if (res.status === 404) return null; | ||
| if (!res.ok) return null; | ||
| return await res.json(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export default async function CaseStatusPage({ params }: { params: Promise<{ ref: string }> }) { | ||
| const { ref } = await params; | ||
|
|
||
| if (!/^FU-[A-Fa-f0-9]{6,12}$/i.test(ref)) { | ||
| notFound(); | ||
| } | ||
|
|
||
| const status = await getCaseStatus(ref); | ||
| const email = status?.support_email || SUPPORT_EMAIL; | ||
|
|
||
| if (!status) { | ||
| return ( | ||
| <div className="min-h-screen bg-zinc-950 flex items-center justify-center p-6"> | ||
| <div className="max-w-md w-full text-center"> | ||
| <h1 className="text-lg font-semibold text-white mb-2">Case Not Found</h1> | ||
| <p className="text-sm text-zinc-400 mb-1"> | ||
| No case found for reference <span className="font-mono text-zinc-300">{ref}</span> | ||
| </p> | ||
| <p className="text-xs text-zinc-500 mt-4"> | ||
| If you believe this is an error, contact{' '} | ||
| <a href={`mailto:${SUPPORT_EMAIL}`} className="text-purple-400 hover:text-purple-300"> | ||
| {SUPPORT_EMAIL} | ||
| </a> | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const meta = STAGE_META[status.stage] ?? STAGE_META['none']; | ||
| const updatedDays = daysSince(status.updated_at); | ||
|
|
||
| return ( | ||
| <div className="min-h-screen bg-zinc-950 flex items-center justify-center p-6"> | ||
| <div className="max-w-md w-full space-y-5"> | ||
| <div className="text-center"> | ||
| <h1 className="text-lg font-semibold text-white">Case Status</h1> | ||
| <p className="text-xs text-zinc-500 font-mono mt-1">{status.case_ref}</p> | ||
| </div> | ||
|
|
||
| <div | ||
| className={cn( | ||
| 'rounded-2xl p-5', | ||
| 'bg-gradient-to-b from-white/[0.03] to-white/[0.01]', | ||
| 'shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_2px_4px_rgba(0,0,0,0.1),0_8px_16px_rgba(0,0,0,0.1)]' | ||
| )} | ||
| > | ||
| <div className={cn('flex items-center gap-2.5 px-3 py-2 rounded-xl mb-4', meta.bg)}> | ||
| <div className={cn('w-2 h-2 rounded-full', meta.dot)} /> | ||
| <span className={cn('text-sm font-medium', meta.text)}>{meta.label}</span> | ||
| </div> | ||
|
|
||
| <div className="space-y-3"> | ||
| <div className="flex justify-between items-center"> | ||
| <span className="text-xs text-zinc-500">Created</span> | ||
| <span className="text-sm text-zinc-300">{formatDate(status.created_at)}</span> | ||
| </div> | ||
| <div className="h-px bg-zinc-800" /> | ||
| <div className="flex justify-between items-center"> | ||
| <span className="text-xs text-zinc-500">Last Updated</span> | ||
| <span className="text-sm text-zinc-300">{formatDate(status.updated_at)}</span> | ||
| </div> | ||
| </div> | ||
|
|
||
| {status.message && ( | ||
| <> | ||
| <div className="h-px bg-zinc-800 my-3" /> | ||
| <p className="text-sm text-zinc-400 leading-relaxed">{status.message}</p> | ||
| </> | ||
| )} | ||
| </div> | ||
|
|
||
| {updatedDays >= 3 && ( | ||
| <div className="rounded-xl bg-zinc-900/50 px-4 py-3"> | ||
| <p className="text-xs text-zinc-400 leading-relaxed"> | ||
| This case hasn't been updated in {updatedDays} days. If you need assistance, please contact{' '} | ||
| <a href={`mailto:${email}`} className="text-purple-400 hover:text-purple-300"> | ||
| {email} | ||
| </a> | ||
| </p> | ||
| </div> | ||
| )} | ||
|
|
||
| <p className="text-center text-xs text-zinc-600"> | ||
| Need help?{' '} | ||
| <a href={`mailto:${email}`} className="text-purple-400/70 hover:text-purple-300"> | ||
| {email} | ||
| </a> | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
UI logic duplicated from
CaseStatusView
The PR description states the design is "same as the web/app version (CaseStatusView)", yet the entire rendering logic — STAGE_META, formatDate, daysSince, and all JSX — is copied verbatim rather than shared. web/app/src/components/fair-use/CaseStatusView.tsx is already an identical component (the two files differ only in where SUPPORT_EMAIL is declared).
If these two apps share a common package (e.g. a packages/ui workspace), the component could live there. Alternatively, if they're truly isolated, at minimum a comment linking to the source component would signal to future maintainers that updates to CaseStatusView should be mirrored here.
Use Number.isNaN(d.getTime()) instead of try/catch — new Date() doesn't throw on invalid input, it returns Invalid Date. Prevents "Invalid Date" rendering and NaN in daysSince. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
web/frontend tsconfig uses @/src/* alias, not @/*. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… validation - Return discriminated CaseResult (ok/not_found/error) instead of collapsing all failures to null — users now see "Something Went Wrong" for 5xx/network errors vs "Case Not Found" for actual 404s - Add AbortSignal.timeout(10s) to upstream fetch call - Validate support_email with regex before interpolating into mailto links Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review fixes (iteration 1)All three reviewer issues addressed in d167141:
by AI for @beastoin |
Stricter allowlist: only RFC 5321 local-part characters and alphanumeric domain labels — rejects ?, %, #, &, newlines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests ref validation, safeEmail (incl. mailto injection), formatDate, daysSince boundaries, STAGE_META fallback, and getCaseStatus fetch behavior (200/404/5xx/network/timeout/signal). Uses Node.js built-in test runner (node:test) — no extra deps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR ready for mergeAll checkpoints passed:
Commits:
Awaiting manager merge approval. by AI for @beastoin |
Local dev screenshot evidence1. Error state — valid ref 2. Invalid ref 404 — 3. Error state — valid ref Note: The prod API currently returns 500 (not 404) for nonexistent case refs — this is a backend issue, not related to this PR. The page correctly renders "Something Went Wrong" for 5xx responses vs "Case Not Found" for 404s. by AI for @beastoin |
|
lgtm |
* Add case status page to h.omi.me (web/frontend)
Public unauthenticated case status lookup at h.omi.me/case/{ref}.
Self-contained server component — same design as web/app version.
Shows stage, dates, message, stale case banner, support email.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix date validation per CODEx review
Use Number.isNaN(d.getTime()) instead of try/catch — new Date()
doesn't throw on invalid input, it returns Invalid Date. Prevents
"Invalid Date" rendering and NaN in daysSince.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix import path: @/src/lib/utils for web/frontend tsconfig
web/frontend tsconfig uses @/src/* alias, not @/*.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix reviewer issues: discriminated error states, fetch timeout, email validation
- Return discriminated CaseResult (ok/not_found/error) instead of collapsing
all failures to null — users now see "Something Went Wrong" for 5xx/network
errors vs "Case Not Found" for actual 404s
- Add AbortSignal.timeout(10s) to upstream fetch call
- Validate support_email with regex before interpolating into mailto links
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Tighten safeEmail regex to block mailto parameter injection
Stricter allowlist: only RFC 5321 local-part characters and
alphanumeric domain labels — rejects ?, %, #, &, newlines.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add 38 unit tests for case status page logic
Tests ref validation, safeEmail (incl. mailto injection), formatDate,
daysSince boundaries, STAGE_META fallback, and getCaseStatus fetch
behavior (200/404/5xx/network/timeout/signal).
Uses Node.js built-in test runner (node:test) — no extra deps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add case status page to h.omi.me (web/frontend)
Public unauthenticated case status lookup at h.omi.me/case/{ref}.
Self-contained server component — same design as web/app version.
Shows stage, dates, message, stale case banner, support email.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix date validation per CODEx review
Use Number.isNaN(d.getTime()) instead of try/catch — new Date()
doesn't throw on invalid input, it returns Invalid Date. Prevents
"Invalid Date" rendering and NaN in daysSince.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix import path: @/src/lib/utils for web/frontend tsconfig
web/frontend tsconfig uses @/src/* alias, not @/*.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix reviewer issues: discriminated error states, fetch timeout, email validation
- Return discriminated CaseResult (ok/not_found/error) instead of collapsing
all failures to null — users now see "Something Went Wrong" for 5xx/network
errors vs "Case Not Found" for actual 404s
- Add AbortSignal.timeout(10s) to upstream fetch call
- Validate support_email with regex before interpolating into mailto links
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Tighten safeEmail regex to block mailto parameter injection
Stricter allowlist: only RFC 5321 local-part characters and
alphanumeric domain labels — rejects ?, %, #, &, newlines.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add 38 unit tests for case status page logic
Tests ref validation, safeEmail (incl. mailto injection), formatDate,
daysSince boundaries, STAGE_META fallback, and getCaseStatus fetch
behavior (200/404/5xx/network/timeout/signal).
Uses Node.js built-in test runner (node:test) — no extra deps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>



Summary
Add public case status page at
h.omi.me/case/FU-XXXXXXso fair-use case references resolve on the marketing domain instead of the authenticated app domain./v1/fair-use/case/{ref}/statusAPIFU-[hex]{6,12}) before fetchingChanges
web/frontend/src/app/case/[ref]/page.tsx— new server componentweb/frontend/src/__tests__/case-status.test.mjs— 38 unit testsReview cycle fixes
CaseResultunion (ok | not_found | error) instead of collapsing all failures to nullAbortSignal.timeout(10_000)on upstream fetchsafeEmail()validates email with RFC 5321 allowlist regex (blocks?,%, newlines)Test coverage (38 tests, all passing)
Test plan
next buildsucceeds with no type errorsnode --test)Evidence
Screenshots uploaded to GCS:
gs://omi-pr-assets/pr-5847/Closes #5582
🤖 Generated with Claude Code
by AI for @beastoin