The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
32 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04 | 01 | execute | 1 |
|
true |
|
|
Purpose: TICK-05 mandates cursor-based ~25/page infinite scroll; the page can't be wired without the new API shape. Co-locating the filter strip and skeleton component here keeps Plan 02 focused on wiring rather than building presentational primitives. Mirror the Phase 3 pattern of exporting TypeScript interfaces from the route file so Plan 02 can import type them directly.
Output:
- Rewritten
app/api/mobile/tickets/route.ts(cursor-based, exported interfaces, capped limit, default-status fallback) - New
components/mobile/TicketFilterStrip.tsx(Collapsible filter strip, search + status/priority/queue/mine controls) - New
components/mobile/TicketRowSkeleton.tsx(5-row skeleton shape matching priority-stripe row layout)
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/REQUIREMENTS.md @.planning/phases/04-tickets-restyle/04-CONTEXT.md @.planning/phases/04-tickets-restyle/04-UI-SPEC.md @.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md @CLAUDE.md @app/api/mobile/tickets/route.ts @components/ui/collapsible.tsx From app/api/mobile/tickets/route.ts (lines 4-26): ```typescript async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> { // reads kiosk_settings: mobile_company_category_ids, mobile_excluded_company_ids // returns { join, condition } where condition is a SQL fragment for tickets table alias `t` } ```From components/ui/collapsible.tsx:
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
// Wrappers around @radix-ui/react-collapsible
// Props: open, onOpenChange — control state externally for URL sync
From components/ui/select.tsx (shadcn):
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
From components/ui/switch.tsx (shadcn):
export { Switch } // controlled via checked + onCheckedChange
From components/ui/skeleton.tsx (shadcn):
export { Skeleton } // div with bg-muted animate-pulse rounded
From components/ui/input.tsx (shadcn):
export { Input }
From components/ui/button.tsx (shadcn):
export { Button } // accepts variant: 'default'|'ghost'|..., size: 'sm'|'default'|'lg'
Pattern from Phase 3 (app/api/mobile/dashboard/route.ts):
import postgresClient from '@/lib/services/postgres-client'(default import)import { requireAuth } from '@/lib/auth-utils'thenconst { session, error } = await requireAuth(); if (error) return error;- Single
Promise.allof queries - Manual snake_case → camelCase NOT done in Phase 3 dashboard (kept snake_case in JSON) — for tickets the existing route returns snake_case so we KEEP snake_case to avoid breaking the page contract
1. **Imports** — keep `NextRequest`, `NextResponse` from `next/server`. Switch to default import: `import postgresClient from '@/lib/services/postgres-client'` (matches Phase 3 dashboard route convention). Add `import { requireAuth } from '@/lib/auth-utils'`.
2. **Preserve `getMobileCompanyFilter()` helper VERBATIM** — copy lines 4-26 of the current file unchanged. Do NOT regress the kiosk_settings lookup or the `c.company_category_id = 1` fallback. The function signature and body must be byte-identical to the current implementation.
3. **Export TypeScript interfaces** at the top of the module (mirrors Phase 3 03-01-SUMMARY.md pattern):
```typescript
export interface MobileTicket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number;
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
```
4. **Cursor encode/decode helpers** (inline, NOT exported — D-09):
```typescript
interface CursorPayload { last_activity_date: string; id: number; }
function encodeCursor(p: CursorPayload): string {
return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
}
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') {
return parsed as CursorPayload;
}
return null;
} catch { return null; }
}
```
5. **`GET` handler** — `export async function GET(request: NextRequest): Promise<NextResponse>`:
- Auth gate first: `const { error: authError } = await requireAuth(); if (authError) return authError;`
- Parse query params:
- `q` (search, may be empty)
- `status` — comma-separated ints; if absent, default to `[1, 8, 7]` (Open + In Progress + Waiting per UI-SPEC "Default behavior when no URL params"); accept `''` as "no filter — explicit clear"; treat empty array same as default
- `priority` — comma-separated ints; if absent, no filter
- `queue` — single int; if absent, no filter
- `mine` — `'1'` means filter by current user; pull email from `session.user.email` then resolve to `resources.email = $X`
- `limit` — parseInt, clamped: `Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')))`
- `cursor` — decode via `decodeCursor()`; null if missing or malformed
- Build conditions array starting with `t.is_deleted = false`, the company-scope condition from `getMobileCompanyFilter()`, and the status filter (default-or-supplied list → `t.status = ANY($N::int[])`).
- Search: keep the existing pattern — `(t.title ILIKE $N OR t.ticket_number ILIKE $N OR c.company_name ILIKE $N)` with single `%search%` param.
- Cursor seek predicate (only if cursor decoded): `(t.last_activity_date, t.id) < ($N::timestamp, $M::int)` — this is the standard keyset pagination form for `ORDER BY last_activity_date DESC, id DESC`.
- Use `requireAuth()`'s session for `mine`: `params.push(session.user.email); conditions.push('LOWER(r.email) = LOWER($N))` — but ONLY if you have access to session here; since `requireAuth()` already returned `{ session, error }`, capture `session` from the call (`const { session, error: authError } = await requireAuth();`).
SQL:
```sql
SELECT t.id, t.ticket_number, t.title, t.status, t.priority,
t.create_date, t.last_activity_date, t.due_date_time,
t.queue_id, q.label AS queue_label,
c.company_name,
COALESCE(r.first_name || ' ' || r.last_name, '') AS assigned_to
FROM tickets t
INNER JOIN companies c ON c.id = t.company_id
LEFT JOIN queues q ON q.value = t.queue_id
LEFT JOIN resources r ON r.id = t.assigned_resource_id
WHERE <conditions joined with AND>
ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC
LIMIT <limit + 1>
```
Fetch `limit + 1` rows to detect `hasMore` without a second COUNT query. If `rows.length > limit`, slice to `limit` and `hasMore = true`; the next-cursor's `last_activity_date` and `id` come from the last kept row (`rows[limit - 1]`).
Build response:
```typescript
const tickets: MobileTicket[] = sliced.map(/* row → MobileTicket; preserve snake_case keys exactly as the interface declares */);
const nextCursor = hasMore ? encodeCursor({ last_activity_date: tickets[tickets.length - 1].last_activity_date, id: tickets[tickets.length - 1].id }) : null;
return NextResponse.json({ tickets, nextCursor, hasMore } satisfies MobileTicketListResponse);
```
6. **Error handling** — wrap the body in `try/catch`; on error log `console.error('GET /api/mobile/tickets failed:', error)` and return `NextResponse.json({ error: 'Failed to fetch tickets', message: error instanceof Error ? error.message : 'unknown' }, { status: 500 })` per CLAUDE.md API route convention.
Anti-patterns (do NOT do):
- Do NOT add Zod validation here (CLAUDE.md: "No Zod validation in route handlers today").
- Do NOT change the snake_case keys of MobileTicket (the original page used `ticket_number`, `last_activity_date`, etc. — Plan 02 expects these names).
- Do NOT remove `requireAuth()` once added — it's a security gate. Note: the legacy file did NOT have `requireAuth()`; this is intentional hardening per Phase 3 pattern.
- Do NOT touch `app/api/mobile/tickets/[id]/timeline/route.ts` — that's the detail endpoint, out of scope.
Per D-09 the cursor encodes `{ last_activity_date, id }` exactly — do not rename to `lastActivityDate` (would break decode round-trip).
npx tsc --noEmit --pretty 2>&1 | grep -E "(app/api/mobile/tickets/route|components/mobile/TicketFilterStrip|components/mobile/TicketRowSkeleton)" || echo "OK: no type errors in target files"
- `grep -q "export interface MobileTicket" app/api/mobile/tickets/route.ts` (interface exported)
- `grep -q "export interface MobileTicketListResponse" app/api/mobile/tickets/route.ts` (envelope interface exported)
- `grep -q "nextCursor" app/api/mobile/tickets/route.ts` (cursor field present)
- `grep -q "hasMore" app/api/mobile/tickets/route.ts` (hasMore field present)
- `grep -q "getMobileCompanyFilter" app/api/mobile/tickets/route.ts` (helper preserved)
- `grep -q "requireAuth" app/api/mobile/tickets/route.ts` (auth gate added)
- `grep -qE "Math\.min\(25" app/api/mobile/tickets/route.ts` (limit cap of 25 — D-11)
- `grep -qE "ORDER BY.*last_activity_date.*DESC" app/api/mobile/tickets/route.ts` (keyset order)
- `grep -qE "t\.id DESC" app/api/mobile/tickets/route.ts` (tie-breaker on id — D-09)
- `! grep -q "OFFSET" app/api/mobile/tickets/route.ts` (no page-based offset remains)
- `! grep -q "?page=" app/api/mobile/tickets/route.ts` (no page param consumed)
- `grep -q "is_deleted" app/api/mobile/tickets/route.ts` (deleted filter preserved)
- `npx tsc --noEmit --pretty 2>&1` does not report errors for `app/api/mobile/tickets/route.ts`
The route file compiles cleanly, exports `MobileTicket` and `MobileTicketListResponse`, returns `{ tickets, nextCursor, hasMore }`, caps limit at 25, applies the `[1, 8, 7]` default status filter when no `status` param is supplied, preserves `getMobileCompanyFilter()` verbatim, and gates with `requireAuth()`. No OFFSET-based pagination remains. The legacy `?page=N` shape is fully replaced.
Task 2: Create TicketRowSkeleton and TicketFilterStrip presentational components
components/mobile/TicketRowSkeleton.tsx, components/mobile/TicketFilterStrip.tsx
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Filter Strip" and "Skeleton Loading State" sections
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-01 through D-04 (filter strip), D-21 (skeleton)
- components/mobile/KpiCardMobile.tsx (Phase 3 component for the comment-block pattern reference)
- components/ui/collapsible.tsx (Collapsible API surface)
- components/ui/skeleton.tsx (Skeleton primitive)
Create two new files. Both use the Phase 3 mobile component comment-block convention:
```
/* ComponentName — phase 04 (TICK-NN).
* Purpose: one-line description.
* Props: ... */
```
---
**File 1: `components/mobile/TicketRowSkeleton.tsx`** (D-21)
```typescript
'use client';
/* TicketRowSkeleton — phase 04 (TICK-05/D-21).
* Purpose: skeleton placeholder row that matches the priority-stripe ticket row layout
* for the initial-load state of /mobile/tickets.
* Props: none — purely presentational. */
import { Skeleton } from '@/components/ui/skeleton';
export function TicketRowSkeleton() {
return (
<div className="border-l-4 border-muted px-4 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2 mt-1" />
<div className="flex gap-2 mt-2 items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-16 ml-auto" />
</div>
</div>
);
}
```
Match UI-SPEC §"Skeleton Loading State" exactly. The wrapping container in Plan 02 will render `Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)`.
---
**File 2: `components/mobile/TicketFilterStrip.tsx`** (D-01..D-04)
Headers and exports:
```typescript
'use client';
/* TicketFilterStrip — phase 04 (TICK-01, TICK-02).
* Purpose: sticky search input + Collapsible filter panel (status, priority, queue, mine)
* with controlled values; URL sync is the parent page's responsibility.
* Props: value, onChange, queueOptions, openTotal, isFiltered, onClearAll. */
import { useState } from 'react';
import { Search, X, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
export interface QueueOption {
id: number;
label: string;
}
export interface TicketFilterValue {
q: string;
status: number[]; // [] = no filter — caller treats default elsewhere
priority: number[]; // [] = no filter
queue: number | null;
mine: boolean;
}
export interface TicketFilterStripProps {
value: TicketFilterValue;
onChange: (next: TicketFilterValue) => void;
queueOptions: QueueOption[];
openTotal: number;
isFiltered: boolean; // true when ≥1 non-default filter is active (excludes default status)
onClearAll: () => void;
}
const STATUS_OPTIONS: Array<{ id: number; label: string }> = [
{ id: 1, label: 'Open' },
{ id: 8, label: 'In Progress' },
{ id: 7, label: 'Waiting' },
];
const PRIORITY_OPTIONS: Array<{ id: number; label: string }> = [
{ id: 1, label: 'Critical' },
{ id: 2, label: 'High' },
{ id: 3, label: 'Medium' },
{ id: 4, label: 'Low' },
];
function chipClass(active: boolean): string {
const base = 'shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors min-h-[32px]';
return active
? `${base} bg-primary text-primary-foreground border-primary`
: `${base} border-border hover:bg-muted/50`;
}
function toggleInArray(arr: number[], id: number): number[] {
return arr.includes(id) ? arr.filter(x => x !== id) : [...arr, id];
}
export function TicketFilterStrip(props: TicketFilterStripProps) {
const { value, onChange, queueOptions, openTotal, isFiltered, onClearAll } = props;
const [open, setOpen] = useState(false);
const activeCount =
(value.status.length > 0 && !(value.status.length === 3 && value.status.includes(1) && value.status.includes(7) && value.status.includes(8)) ? 1 : 0) +
(value.priority.length > 0 ? 1 : 0) +
(value.queue !== null ? 1 : 0) +
(value.mine ? 1 : 0);
return (
<div className="sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2">
{/* Search row — always visible (D-03) */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" aria-hidden="true" />
<Input
type="text"
placeholder="Search tickets, company…"
value={value.q}
onChange={(e) => onChange({ ...value, q: e.target.value })}
className="w-full pl-9 pr-9"
aria-label="Search tickets"
/>
{value.q && (
<button
type="button"
onClick={() => onChange({ ...value, q: '' })}
className="absolute right-3 top-1/2 -translate-y-1/2"
aria-label="Clear search"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Toggle row — always visible (D-01) */}
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{openTotal} open tickets</p>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs font-semibold" aria-label="Toggle filters">
<SlidersHorizontal className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
Filters{activeCount > 0 ? ` (${activeCount})` : ''}
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" aria-hidden="true" />}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="pt-3 space-y-3">
{/* Status (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Status</p>
<div className="flex gap-2 flex-wrap">
{STATUS_OPTIONS.map((s) => (
<button
key={s.id}
type="button"
role="checkbox"
aria-checked={value.status.includes(s.id)}
onClick={() => onChange({ ...value, status: toggleInArray(value.status, s.id) })}
className={chipClass(value.status.includes(s.id))}
>
{s.label}
</button>
))}
</div>
</div>
{/* Priority (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Priority</p>
<div className="flex gap-2 flex-wrap">
{PRIORITY_OPTIONS.map((p) => (
<button
key={p.id}
type="button"
role="checkbox"
aria-checked={value.priority.includes(p.id)}
onClick={() => onChange({ ...value, priority: toggleInArray(value.priority, p.id) })}
className={chipClass(value.priority.includes(p.id))}
>
{p.label}
</button>
))}
</div>
</div>
{/* Queue (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Queue</p>
<Select
value={value.queue !== null ? String(value.queue) : 'all'}
onValueChange={(v) => onChange({ ...value, queue: v === 'all' ? null : parseInt(v, 10) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All queues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All queues</SelectItem>
{queueOptions.map((q) => (
<SelectItem key={q.id} value={String(q.id)}>{q.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Assigned to me (D-02) */}
<div className="flex items-center justify-between">
<label htmlFor="filter-mine" className="text-sm">Assigned to me</label>
<Switch
id="filter-mine"
checked={value.mine}
onCheckedChange={(checked) => onChange({ ...value, mine: checked })}
/>
</div>
{/* Clear all — only when isFiltered (D-04) */}
{isFiltered && (
<button
type="button"
onClick={onClearAll}
className="text-xs text-muted-foreground underline"
>
Clear all
</button>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}
```
Notes on behavior:
- The component is purely controlled (no internal filter state besides the open/closed Collapsible toggle). URL sync lives in the parent page.
- `activeCount` excludes the default `[1, 7, 8]` status set so the badge only counts user-selected modifications. The parent decides what "default" means and passes `isFiltered` accordingly.
- The `min-h-[32px]` on chips meets the touch-target guidance with `py-1` baseline; the entire chip area is tappable. (UI-SPEC notes 44px for primary controls; chips are secondary and use a relaxed target consistent with the existing `py-1 rounded-full` pattern.)
Anti-patterns (do NOT do):
- Do NOT introduce SWR / react-query / Zustand (CLAUDE.md: "No additional state libraries").
- Do NOT fetch queue options inside this component; the parent passes `queueOptions` (queue list comes from a future endpoint or from the existing tickets API; Plan 02 will decide). For Plan 01 we only define the prop contract.
- Do NOT render the priority dot — the row stripe replaces it (D-17). This component only handles filters; the row itself is built in Plan 02.
npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(TicketRowSkeleton|TicketFilterStrip)" || echo "OK: no type errors in new components"
- `test -f components/mobile/TicketRowSkeleton.tsx` (file exists)
- `test -f components/mobile/TicketFilterStrip.tsx` (file exists)
- `grep -q "export function TicketRowSkeleton" components/mobile/TicketRowSkeleton.tsx` (named export)
- `grep -q "export function TicketFilterStrip" components/mobile/TicketFilterStrip.tsx` (named export)
- `grep -q "export interface TicketFilterValue" components/mobile/TicketFilterStrip.tsx` (filter value interface exported)
- `grep -q "export interface QueueOption" components/mobile/TicketFilterStrip.tsx` (queue option interface exported)
- `grep -q "border-l-4 border-muted" components/mobile/TicketRowSkeleton.tsx` (4px stripe per UI-SPEC)
- `grep -q "from '@/components/ui/collapsible'" components/mobile/TicketFilterStrip.tsx` (uses Collapsible primitive — D-01)
- `grep -q "sticky top-0" components/mobile/TicketFilterStrip.tsx` (sticky positioning per UI-SPEC viewport contract)
- `grep -q "Clear all" components/mobile/TicketFilterStrip.tsx` (D-04 copy)
- `grep -qE "(Open|In Progress|Waiting)" components/mobile/TicketFilterStrip.tsx` (status options — D-02)
- `grep -qE "(Critical|High|Medium|Low)" components/mobile/TicketFilterStrip.tsx` (priority options — D-02)
- `grep -q "Assigned to me" components/mobile/TicketFilterStrip.tsx` (mine toggle label — D-02)
- `grep -q "phase 04" components/mobile/TicketRowSkeleton.tsx && grep -q "phase 04" components/mobile/TicketFilterStrip.tsx` (Phase 3 comment-block convention)
- `! grep -q "useSWR\|@tanstack/react-query\|zustand" components/mobile/TicketFilterStrip.tsx` (no forbidden state libraries)
- `npx tsc --noEmit --pretty 2>&1` does not report errors for either new file
Both component files exist, type-check cleanly, expose the documented prop interfaces, follow the Phase 3 comment-block convention, and contain the exact UI-SPEC class strings for the priority skeleton stripe (`border-l-4 border-muted`) and the sticky filter strip container (`sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2`). Plan 02 can `import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip'` and `import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton'` without further changes.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| client → /api/mobile/tickets | Authenticated user supplies q/status/priority/queue/cursor — must be validated and parameterised |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-01 | Tampering | cursor query param | mitigate | decodeCursor() returns null on JSON parse failure, missing fields, or wrong types — falls back to "no cursor" rather than throwing or trusting parsed data; the cursor only affects ordering, never company scope. |
| T-04-02 | Information Disclosure | company-scope bypass via SQL injection | mitigate | All user input flows through parameterised queries via postgresClient.query(sql, params); getMobileCompanyFilter() interpolates only ints filtered through parseInt + isNaN guards (existing helper, preserved verbatim). |
| T-04-03 | Information Disclosure | unauthenticated access to ticket list | mitigate | Add await requireAuth() at the top of GET — the legacy route lacked this, Phase 4 hardens it (matches Phase 3 dashboard route). |
| T-04-04 | Denial of Service | unbounded limit param |
mitigate | Server-side cap: Math.min(25, Math.max(1, parseInt(limit ?? '25'))) — caller cannot request more than 25 rows. |
| T-04-05 | Information Disclosure | mine filter using session.user.email |
mitigate | Email comes from the verified session, never from the query string; SQL uses LOWER(r.email) = LOWER($N) parameterised. |
| T-04-06 | Spoofing | filter chips submit forged status/priority ids | accept | Status/priority are foreign keys to tickets; non-existent ids simply return zero rows. No data exfiltration risk; legacy route had the same model. |
| </threat_model> |
npx tsc --noEmit --pretty— must pass with no new errors inapp/api/mobile/tickets/route.ts,components/mobile/TicketRowSkeleton.tsx, orcomponents/mobile/TicketFilterStrip.tsx.grep -c "export interface" app/api/mobile/tickets/route.ts— must be>= 2(MobileTicket + MobileTicketListResponse).grep -c "export function" components/mobile/TicketFilterStrip.tsx— must be>= 1.- Hand-execute one curl for sanity (developer terminal):
curl -sS 'http://localhost:3100/api/mobile/tickets?limit=5' -b "<session-cookie>" | jq '.tickets | length, .nextCursor, .hasMore'— should return5, an opaque base64 string (or null if fewer than 5 tickets), and a boolean. NOTE: this is for the dev's smoke check; not part of the automated gate (it requires a live dev server).
<success_criteria>
app/api/mobile/tickets/route.tsrewritten with cursor pagination,requireAuth(), exportedMobileTicketandMobileTicketListResponseinterfaces, server-side limit cap of 25, default-status fallback[1, 8, 7], andgetMobileCompanyFilter()preserved verbatim.components/mobile/TicketRowSkeleton.tsxexists with the exact UI-SPEC skeleton shape (4px muted stripe + 3 skeleton lines + metadata row).components/mobile/TicketFilterStrip.tsxexists with the controlled prop contract, Collapsible-driven panel, all four filter controls, "Clear all" button, and active-filter count badge.- TypeScript clean (
npx tsc --noEmit --prettypasses for these files). - No legacy
?page=/OFFSETpaths remain in the route. - All TICK-01 (filter strip), TICK-02 (URL sync — interface contract ready for Plan 02), TICK-05 (cursor API) requirements substantially landed (the page wiring closes them in Plan 02). </success_criteria>