Skip to content

Add case status page to h.omi.me#5847

Merged
beastoin merged 6 commits intomainfrom
feat/case-status-h-omi-me
Mar 20, 2026
Merged

Add case status page to h.omi.me#5847
beastoin merged 6 commits intomainfrom
feat/case-status-h-omi-me

Conversation

@beastoin
Copy link
Copy Markdown
Collaborator

@beastoin beastoin commented Mar 20, 2026

Summary

Add public case status page at h.omi.me/case/FU-XXXXXX so fair-use case references resolve on the marketing domain instead of the authenticated app domain.

  • Self-contained Next.js server component — no auth, no Firebase dependency
  • Fetches case status from /v1/fair-use/case/{ref}/status API
  • Validates case ref format (FU-[hex]{6,12}) before fetching
  • Discriminated error handling: separate UI for 404 (not found) vs 5xx/network errors
  • 10s fetch timeout via AbortSignal
  • Email validation before mailto interpolation (RFC 5321 allowlist, blocks injection chars)
  • Shows stage badge, dates, message, and stale-case banner (≥3 days)

Changes

  • web/frontend/src/app/case/[ref]/page.tsx — new server component
  • web/frontend/src/__tests__/case-status.test.mjs — 38 unit tests

Review cycle fixes

  • Discriminated CaseResult union (ok | not_found | error) instead of collapsing all failures to null
  • AbortSignal.timeout(10_000) on upstream fetch
  • safeEmail() validates email with RFC 5321 allowlist regex (blocks ?, %, newlines)

Test coverage (38 tests, all passing)

  • Ref format validation (9 tests): valid/invalid refs, case-insensitivity, length bounds, path traversal
  • safeEmail (10 tests): valid passthrough, undefined/null/empty fallback, mailto injection chars
  • formatDate (4 tests): valid ISO, invalid date, empty string, epoch
  • daysSince (6 tests): invalid, today, past, future, 3-day boundary
  • STAGE_META (2 tests): all stages present, unknown fallback
  • getCaseStatus (7 tests): 200/404/500/network/timeout, URL encoding, signal passing

Test plan

  • Local dev server renders case-not-found page for valid but nonexistent ref
  • Local dev server returns 404 for invalid ref format
  • Playwright screenshots captured as evidence
  • next build succeeds with no type errors
  • 38/38 unit tests pass (node --test)

Evidence

Screenshots uploaded to GCS: gs://omi-pr-assets/pr-5847/

Closes #5582

🤖 Generated with Claude Code

by AI for @beastoin

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 20, 2026

Greptile Summary

This PR adds a /case/{ref} route to h.omi.me (web/frontend), mirroring the case-status page that already exists in web/app. It is a self-contained, auth-free Next.js server component that fetches case data from the backend API and renders a status card.

The implementation is broadly correct and consistent with existing patterns in the codebase. A few things worth addressing:

  • No page metadata — unlike other web/frontend pages (e.g. unlimited/page.tsx), there is no generateMetadata export, so the browser tab shows the generic "Omi" title.
  • API error ≡ "Case Not Found" — both genuine 404 responses and transient server errors (5xx, network timeouts) render the same "Case Not Found" UI, which could confuse users whose case actually exists.
  • Full UI duplication — rather than reusing CaseStatusView, all logic (STAGE_META, formatDate, daysSince, JSX) is copied verbatim. The two files are currently identical in behaviour; any future update to CaseStatusView will need to be manually applied here as well.

Confidence Score: 4/5

  • Safe to merge; no correctness or security issues — only UX and maintainability suggestions.
  • The page is functionally correct: it validates the ref format, fetches from the right endpoint with no-store caching, and renders an appropriate UI. The only issues are cosmetic/UX (missing metadata), UX (error states conflated with not-found), and maintainability (duplicated component logic). None of these block the feature from working.
  • No files require special attention beyond the single changed file.

Important Files Changed

Filename Overview
web/frontend/src/app/case/[ref]/page.tsx New server component that adds a /case/[ref] route to h.omi.me. Functionally correct; minor issues include missing page metadata, API error states being surfaced as "Case Not Found", and full duplication of the existing CaseStatusView component logic.

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: "Add case status page..."

Comment on lines +68 to +85
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>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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'.

Comment on lines +1 to +5
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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.',
  };
}

Comment on lines +7 to +150
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&apos;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>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

beastoin and others added 3 commits March 20, 2026 02:48
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>
@beastoin
Copy link
Copy Markdown
Collaborator Author

Review fixes (iteration 1)

All three reviewer issues addressed in d167141:

  1. High — discriminated error states: getCaseStatus now returns CaseResult union (ok | not_found | error). 5xx/network errors render "Something Went Wrong" with retry guidance; only 404 renders "Case Not Found".
  2. Medium — fetch timeout: Added AbortSignal.timeout(10_000) to the upstream fetch call.
  3. Medium — email validation: New safeEmail() validates support_email against a basic regex before use in mailto: links; falls back to SUPPORT_EMAIL constant.

by AI for @beastoin

beastoin and others added 2 commits March 20, 2026 03:05
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>
@beastoin
Copy link
Copy Markdown
Collaborator Author

PR ready for merge

All checkpoints passed:

  • CP7 — Reviewer approved (iteration 3): discriminated error states, fetch timeout, RFC 5321 email validation
  • CP8 — Tester approved: 38/38 unit tests passing across 6 coverage areas

Commits:

  1. 65a6eef — Initial case status page
  2. 09df1a0 — Fix date validation
  3. fd3e765 — Fix import path for web/frontend tsconfig
  4. d167141 — Discriminated error states, fetch timeout, email validation
  5. 449022d — Tighten safeEmail regex (block mailto injection)
  6. 0b59dc6 — 38 unit tests

Awaiting manager merge approval.

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

Local dev screenshot evidence

1. Error state — valid ref FU-000000, API returns 500 (backend returns 500 for nonexistent cases):
error state

2. Invalid ref 404INVALID-REF triggers notFound(), Next.js 404 page:
invalid ref 404

3. Error state — valid ref FU-ABCDEF123456, API returns 500:
error 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

@beastoin
Copy link
Copy Markdown
Collaborator Author

lgtm

@beastoin beastoin merged commit 741ec0e into main Mar 20, 2026
2 of 3 checks passed
@beastoin beastoin deleted the feat/case-status-h-omi-me branch March 20, 2026 03:33
kodjima33 pushed a commit that referenced this pull request Mar 20, 2026
* 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>
Glucksberg pushed a commit to Glucksberg/omi-local that referenced this pull request Apr 28, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

English text incorrectly shows 'translated by omi' badge — missing same-language guard in translate pipeline

1 participant