# Lost Shipments — Implementation Plan (Reviewed)

This document describes how to build the **Lost Shipments** feature (ROADMAP item #4). It follows the DHL Charges feature as the closest pattern analogue.

**Review status:** Eng-reviewed 2026-03-16. Decisions marked with `[R#]` reference review issues.

---

## Table of Contents

1. [Feature Overview](#1-feature-overview)
2. [Database Layer](#2-database-layer)
3. [Schema Layer](#3-schema-layer)
4. [Database Methods](#4-database-methods)
5. [API Layer](#5-api-layer)
6. [Route Registration](#6-route-registration)
7. [Shipment Integration](#7-shipment-integration)
8. [Frontend](#8-frontend)
9. [Testing](#9-testing)
10. [Verification](#10-verification)

---

## Architecture

```
                          ┌─────────────────────┐
                          │   BigShipment.jsx    │
                          │  "Report Lost" btn   │
                          └──────────┬───────────┘
                                     │ PUT /admin/shipper/shipment/:id
                                     │ { action: "ReportLost" }
                                     ▼
                          ┌─────────────────────┐
                          │  ShippingAdminApi    │
                          │  handleReportLost()  │
                          └──────────┬───────────┘
                                     │ creates case
                                     ▼
┌──────────────────┐     ┌─────────────────────┐     ┌──────────────────┐
│ LostShipmentsPage│────▶│  LostShipmentApi     │────▶│ FabricatorDB     │
│  (list + modal)  │     │  getCases/getCase/   │     │ CRUD methods     │
│                  │◀────│  createCase/update   │◀────│                  │
└──────────────────┘     └─────────────────────┘     └──────────────────┘
                                                              │
                                                              ▼
                                                     ┌──────────────────┐
                                                     │ big_lost_shipment│
                                                     │ _cases (Postgres)│
                                                     └──────────────────┘
```

**Claim Status State Machine** `[R2]` — free transitions allowed, all logged to `statusHistory`:
```
  ┌──────────┐    ┌─────────────┐    ┌─────────────────┐
  │ Reported │───▶│ ClaimFiled  │───▶│ PendingCarrier  │
  └──────────┘    └─────────────┘    └────────┬────────┘
                                              │
                                    ┌─────────┴─────────┐
                                    ▼                   ▼
                              ┌──────────┐        ┌──────────┐
                              │ Approved │        │  Denied  │
                              └─────┬────┘        └─────┬────┘
                                    │                   │
                                    ▼                   ▼
                              ┌──────────────────────────┐
                              │        Resolved          │
                              └──────────────────────────┘

  Note: Any→Any is allowed. Staff need flexibility for edge cases
  (carrier reopens denied claim, etc.). Every transition is logged
  in statusHistory JSONB with { from, to, changedBy, changedAt }.
```

---

## Scope

### In scope
- Database: enum, table, indexes, trigger
- Schema: `BigLostShipmentCase` class with `statusHistory` audit trail `[R2]`
- Database methods: CRUD + duplicate-aware create `[R1]`
- API: 4 endpoints in `LostShipmentApi.ts`
- Shipment integration: `ReportLost` action + `lostShipmentCase` on extended response `[R3]`
- Frontend: list page with inline edit modal, nav link, "Report Lost" button
- Tests: full coverage for 26 codepaths `[R7]`

### NOT in scope (deferred)
| Item | Rationale |
|------|-----------|
| Document upload UI (`LostShipmentDocuments.jsx`) | Requires new S3 bucket + IAM + endpoint. `documents` JSONB column exists for V2. See `TODOS.md`. |
| S3 upload endpoint | Blocked by above. |
| Carrier claim filing APIs (DHL/UPS) | Speculative automation. Manual workflow sufficient for V1. |
| Zendesk/Freshdesk integration | CS ticket linking is manual (paste URL) for V1. |
| Dedicated detail page (`/shipper/lost/:id`) | Modal on list page covers the same functionality with fewer files. |

### Files

| Type | Files |
|------|-------|
| **New** (3) | `api/src/fabricator/LostShipmentApi.ts`, `webapps/src/components/BigLogistics/LostShipmentsPage.jsx`, `tests/fabricator/155.LostShipmentApi.ts` |
| **Modified** (8) | `FabricatorSchemas.ts`, `FabricatorDatabase.ts`, `ShippingAdminApi.ts`, `admin_api.ts`, `beyond_db_setup.ts`, `BigShipperWrapper.jsx`, `BigShipment.jsx`, `App.jsx` |

---

## Review Decisions

| # | Issue | Decision |
|---|-------|----------|
| R1 | Duplicate case creation (two creation paths) | Add duplicate check to `POST /admin/lost-shipments` — query for existing non-resolved cases on same `bigShipmentId` |
| R2 | No state machine enforcement | Allow free transitions + log every change in `statusHistory` JSONB field |
| R3 | No Lost status on BigShipment | Don't add Lost to enum. Add `lostShipmentCase` to `getBigShipmentExtended` response instead |
| R4 | DRY query builder | Use `PostgresUtils.SearchQuery` for equality filters, manual clauses only for range queries |
| R5 | Unsafe `currentShippingLabel` access | Add `getTrackingNumber()` and `getCarrier()` helpers on `BigShipment` class |
| R6 | JSONB serialization risk | Add `static readonly JsonbColumns` list, auto-stringify in insert/update methods |
| R7 | Test gaps (11 of 26 paths missing) | Full coverage: 26 test cases, create test shipment in `before()` hook |
| R8 | Extra DB query on shipment detail | Single indexed query on extended endpoint only, wrapped in try/catch |

---

## 1. Feature Overview

When a customer reports a package as lost in transit, CS creates a ticket. This feature:

- Links CS tickets to BigShipment records in Arda
- Tracks claim status through the carrier dispute lifecycle
- Provides visibility into all open lost shipment cases
- Allows marking a shipment as "Lost" directly from the shipment detail page
- Logs all status transitions for audit trail `[R2]`

---

## 2. Database Layer

### 2a. Register in `PostgresInfo`

**File:** `api/src/fabricator/FabricatorDatabase.ts` (lines 6–46)

```typescript
// In PostgresInfo.Enums:
public static LostShipmentCaseStatus: string = "lost_shipment_case_status_enum";

// In PostgresInfo.Tables:
public static LostShipmentCases: string = "big_lost_shipment_cases";
```

### 2b. Create table in `beyond_db_setup.ts`

**File:** `apps/db_setup/beyond_db_setup.ts` — add after DHL tables section (~line 677):

```typescript
// 1. Create enum
try {
    let sql = `
        DO $$ BEGIN
            CREATE TYPE ${PostgresInfo.Enums.LostShipmentCaseStatus} AS ENUM (
                'reported', 'claim_filed', 'pending_carrier',
                'approved', 'denied', 'resolved'
            );
        EXCEPTION
            WHEN duplicate_object THEN null;
        END $$;
    `;
    await client.query(sql);
} catch (e) {
    Logger.error(e);
}

// 2. Create table
try {
    const sql = `
        CREATE TABLE IF NOT EXISTS ${PostgresInfo.Tables.LostShipmentCases} (
            "uniqueId" uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
            id VARCHAR(32) NOT NULL UNIQUE,
            "createdAt" BIGINT NOT NULL,
            "bigShipmentId" VARCHAR(64) NOT NULL,
            "trackingNumber" VARCHAR(255) DEFAULT '',
            "carrier" VARCHAR(64) DEFAULT '',
            "customerEmail" VARCHAR(255) DEFAULT '',
            "csTicketId" VARCHAR(255) DEFAULT '',
            "csTicketUrl" TEXT DEFAULT '',
            "claimStatus" ${PostgresInfo.Enums.LostShipmentCaseStatus} DEFAULT 'reported' NOT NULL,
            "claimAmount" NUMERIC(12, 2) DEFAULT 0,
            "claimCurrency" VARCHAR(8) DEFAULT 'USD',
            "carrierClaimId" VARCHAR(255) DEFAULT '',
            "documents" JSONB DEFAULT '[]'::jsonb,
            "statusHistory" JSONB DEFAULT '[]'::jsonb,
            "notes" TEXT DEFAULT '',
            "reportedAt" BIGINT,
            "claimFiledAt" BIGINT,
            "resolvedAt" BIGINT,
            "creatorBigscreenAccountId" VARCHAR(64) NOT NULL,
            "lastUpdatedAtTimestamp" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
            "createdAtTimestamp" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
        );

        CREATE INDEX IF NOT EXISTS idx_lost_shipment_cases_shipment
            ON ${PostgresInfo.Tables.LostShipmentCases}("bigShipmentId");
        CREATE INDEX IF NOT EXISTS idx_lost_shipment_cases_status
            ON ${PostgresInfo.Tables.LostShipmentCases}("claimStatus");
        CREATE INDEX IF NOT EXISTS idx_lost_shipment_cases_tracking
            ON ${PostgresInfo.Tables.LostShipmentCases}("trackingNumber");
    `;
    await client.query(sql);
} catch (e) {
    Logger.error(`${PostgresInfo.Tables.LostShipmentCases}`);
    Logger.error(e);
}

// 3. Trigger
await createTrigger(PostgresInfo.Tables.LostShipmentCases);
```

Key differences from original plan: added `"statusHistory" JSONB` column `[R2]`.

---

## 3. Schema Layer

**File:** `api/src/fabricator/FabricatorSchemas.ts` — add after `DHLChargeFlag` class (line 4646).

### 3a. Enum

```typescript
export enum LostShipmentCaseStatus {
    Reported = "reported",
    ClaimFiled = "claim_filed",
    PendingCarrier = "pending_carrier",
    Approved = "approved",
    Denied = "denied",
    Resolved = "resolved"
}
```

### 3b. Supporting types

```typescript
export interface LostShipmentDocument {
    key: string;           // S3 object key
    fileName: string;
    uploadedAt: number;    // Unix ms
    uploadedBy: string;    // bigscreenAccountId
}

export interface LostShipmentStatusChange {
    from: string;
    to: string;
    changedBy: string;     // bigscreenAccountId
    changedAt: number;     // Unix ms
}
```

### 3c. Schema class `[R6]`

```typescript
export class BigLostShipmentCase extends PostgresDatabase.PostgresRow {
    public bigShipmentId: string = "";
    public trackingNumber: string = "";
    public carrier: string = "";
    public customerEmail: string = "";
    public csTicketId: string = "";
    public csTicketUrl: string = "";
    public claimStatus: LostShipmentCaseStatus = LostShipmentCaseStatus.Reported;
    public claimAmount: number = 0;
    public claimCurrency: string = "USD";
    public carrierClaimId: string = "";
    public documents: LostShipmentDocument[] = [];
    public statusHistory: LostShipmentStatusChange[] = [];
    public notes: string = "";
    public reportedAt: number = 0;
    public claimFiledAt: number = 0;
    public resolvedAt: number = 0;
    public creatorBigscreenAccountId: string = "";

    // [R6] JSONB columns — auto-stringified in insert/update
    public static readonly JsonbColumns: string[] = ["documents", "statusHistory"];

    public static readonly Columns: string[] = [
        "id", "createdAt", "bigShipmentId", "trackingNumber", "carrier",
        "customerEmail", "csTicketId", "csTicketUrl", "claimStatus",
        "claimAmount", "claimCurrency", "carrierClaimId", "documents",
        "statusHistory", "notes", "reportedAt", "claimFiledAt", "resolvedAt",
        "creatorBigscreenAccountId"
    ];

    public static get UpdateColumns(): string[] {
        return [
            "claimStatus", "claimAmount", "claimCurrency", "carrierClaimId",
            "documents", "statusHistory", "notes", "csTicketId", "csTicketUrl",
            "customerEmail", "claimFiledAt", "resolvedAt"
        ];
    }

    constructor(data: Partial<BigLostShipmentCase>) {
        super(data);
        if (data.bigShipmentId) this.bigShipmentId = data.bigShipmentId;
        if (data.trackingNumber) this.trackingNumber = data.trackingNumber;
        if (data.carrier) this.carrier = data.carrier;
        if (data.customerEmail !== undefined) this.customerEmail = data.customerEmail;
        if (data.csTicketId !== undefined) this.csTicketId = data.csTicketId;
        if (data.csTicketUrl !== undefined) this.csTicketUrl = data.csTicketUrl;
        if (data.claimStatus) this.claimStatus = data.claimStatus;
        if (data.claimAmount !== undefined) this.claimAmount = data.claimAmount;
        if (data.claimCurrency) this.claimCurrency = data.claimCurrency;
        if (data.carrierClaimId !== undefined) this.carrierClaimId = data.carrierClaimId;
        if (data.documents) this.documents = data.documents;
        if (data.statusHistory) this.statusHistory = data.statusHistory;
        if (data.notes !== undefined) this.notes = data.notes;
        if (data.reportedAt) this.reportedAt = data.reportedAt;
        if (data.claimFiledAt) this.claimFiledAt = data.claimFiledAt;
        if (data.resolvedAt) this.resolvedAt = data.resolvedAt;
        if (data.creatorBigscreenAccountId) this.creatorBigscreenAccountId = data.creatorBigscreenAccountId;
    }

    // [R6] Auto-stringify JSONB columns
    private static serializeJsonb(data: any): any {
        for (const col of BigLostShipmentCase.JsonbColumns) {
            if (data[col] !== undefined) {
                data[col] = JSON.stringify(data[col]);
            }
        }
        return data;
    }

    public getPostgresInsertValues(): any[] {
        let data: any = BigLostShipmentCase.serializeJsonb(Object.assign({}, this));
        return BigLostShipmentCase.Columns.map((c) => data[c]);
    }

    public getPostgresUpdateValues(): any[] {
        let data: any = BigLostShipmentCase.serializeJsonb(Object.assign({}, this));
        let vals = BigLostShipmentCase.UpdateColumns.map((c) => data[c]);
        vals.push(this.uniqueId);
        return vals;
    }

    // [R2] Log a status transition
    public addStatusChange(from: string, to: string, changedBy: string) {
        this.statusHistory.push({
            from, to, changedBy,
            changedAt: Date.now()
        });
    }
}
```

### 3d. Helper methods on BigShipment `[R5]`

**File:** `api/src/fabricator/FabricatorSchemas.ts` — add to `BigShipment` class (after line ~4175):

```typescript
/**
 * Safely extract tracking number from the shipping label.
 * [R5] Centralizes access to the untyped currentShippingLabel.
 */
public getTrackingNumber(): string {
    return this.currentShippingLabel?.tracking_number || "";
}

/**
 * Safely extract carrier/provider from the shipping label.
 */
public getCarrier(): string {
    return this.currentShippingLabel?.rate?.provider || "";
}
```

---

## 4. Database Methods

**File:** `api/src/fabricator/FabricatorDatabase.ts`

### 4a. Create

```typescript
export async function createLostShipmentCase(
    item: FabricatorSchemas.BigLostShipmentCase
): Promise<FabricatorSchemas.BigLostShipmentCase> {
    const client = await PostgresDatabase.Postgres.getFabricatorClient();
    try {
        const cols = FabricatorSchemas.BigLostShipmentCase.Columns.map((c) => `"${c}"`).join(", ");
        const colIds = FabricatorSchemas.BigLostShipmentCase.Columns.map((c, i) => `$${i + 1}`).join(", ");
        let values: any = item.getPostgresInsertValues();
        const query = `INSERT INTO ${PostgresInfo.Tables.LostShipmentCases} (${cols}) VALUES (${colIds}) RETURNING *;`;
        const result = await client.query(query, values);
        return new FabricatorSchemas.BigLostShipmentCase(result.rows[0]);
    } catch (e) {
        Logger.error("[createLostShipmentCase] Error: " + e.message);
        throw e;
    } finally {
        client.release();
    }
}
```

### 4b. Get by ID

```typescript
export async function getLostShipmentCaseById(id: string): Promise<FabricatorSchemas.BigLostShipmentCase> {
    const client = await PostgresDatabase.Postgres.getFabricatorClient();
    let row = null;
    try {
        try {
            const result = await client.query(
                `SELECT * FROM ${PostgresInfo.Tables.LostShipmentCases} WHERE "uniqueId" = $1;`, [id]
            );
            if (result.rows.length === 1) row = result.rows[0];
        } catch (e) { /* not a valid uuid */ }
        if (!row) {
            const result2 = await client.query(
                `SELECT * FROM ${PostgresInfo.Tables.LostShipmentCases} WHERE id = $1;`, [id]
            );
            if (result2.rows.length === 1) row = result2.rows[0];
        }
    } catch (e) {
        Logger.error(`[getLostShipmentCaseById] Error: ${e.message}`);
    }
    client.release();
    return row ? new FabricatorSchemas.BigLostShipmentCase(row) : null;
}
```

### 4c. List with filtering `[R4]`

Use `PostgresUtils.SearchQuery` for equality filters, add manual range clauses:

```typescript
export type GetLostShipmentCasesParams = {
    bigShipmentId?: string;
    trackingNumber?: string;
    claimStatus?: string;
    carrier?: string;
    minCreatedAt?: number;
    maxCreatedAt?: number;
}

export async function getLostShipmentCases(
    params: GetLostShipmentCasesParams, limit?: number, offset?: number
): Promise<QueryResult<FabricatorSchemas.BigLostShipmentCase>> {
    // [R4] Use SearchQuery for equality filters
    const equalityParams: any = {};
    if (params.bigShipmentId) equalityParams.bigShipmentId = params.bigShipmentId;
    if (params.trackingNumber) equalityParams.trackingNumber = params.trackingNumber;
    if (params.claimStatus) equalityParams.claimStatus = params.claimStatus;
    if (params.carrier) equalityParams.carrier = params.carrier;

    // For range queries, fall back to manual building
    // (SearchQuery doesn't support >= / <= operators)
    if (params.minCreatedAt || params.maxCreatedAt) {
        // Manual query with range support
        let rows: any[] = [];
        const client = await PostgresDatabase.Postgres.getFabricatorClient();
        try {
            let queries = [];
            let values: any[] = [];
            for (const key in equalityParams) {
                queries.push(`${PostgresInfo.Tables.LostShipmentCases}."${key}" = $${values.length + 1}`);
                values.push(equalityParams[key]);
            }
            if (params.minCreatedAt) {
                queries.push(`${PostgresInfo.Tables.LostShipmentCases}."createdAt" >= $${values.length + 1}`);
                values.push(params.minCreatedAt);
            }
            if (params.maxCreatedAt) {
                queries.push(`${PostgresInfo.Tables.LostShipmentCases}."createdAt" <= $${values.length + 1}`);
                values.push(params.maxCreatedAt);
            }
            let query = `SELECT *, COUNT(*) OVER() AS "TotalCount" FROM ${PostgresInfo.Tables.LostShipmentCases}`;
            if (queries.length > 0) query += ` WHERE ${queries.join(" AND ")}`;
            query += ` ORDER BY "createdAt" DESC`;
            if (limit) query += ` LIMIT ${limit}`;
            if (offset) query += ` OFFSET ${offset}`;
            const result = await client.query(query, values);
            rows = result.rows;
        } catch (e) {
            Logger.error("[getLostShipmentCases] Error: " + e.message);
        }
        client.release();
        return new QueryResult<FabricatorSchemas.BigLostShipmentCase>({
            items: rows.map(item => new FabricatorSchemas.BigLostShipmentCase(item)),
            count: (rows[0] && rows[0]["TotalCount"]) ? parseInt(rows[0]["TotalCount"]) : 0
        });
    }

    // Simple equality-only query uses SearchQuery
    const rows = await PostgresUtils.SearchQuery(
        PostgresInfo.Tables.LostShipmentCases, equalityParams, limit, offset
    );
    return new QueryResult<FabricatorSchemas.BigLostShipmentCase>({
        items: rows.map(item => new FabricatorSchemas.BigLostShipmentCase(item)),
        count: rows.length
    });
}
```

### 4d. Get by shipment ID (for duplicate check + extended response)

```typescript
export async function getLostShipmentCaseByShipmentId(
    bigShipmentId: string
): Promise<FabricatorSchemas.BigLostShipmentCase | null> {
    const rows = await PostgresUtils.SearchQuery(
        PostgresInfo.Tables.LostShipmentCases,
        { bigShipmentId },
        1  // limit to most recent
    );
    return rows.length > 0 ? new FabricatorSchemas.BigLostShipmentCase(rows[0]) : null;
}
```

### 4e. Update

```typescript
export async function updateLostShipmentCase(item: FabricatorSchemas.BigLostShipmentCase) {
    const client = await PostgresDatabase.Postgres.getFabricatorClient();
    try {
        const updateVals = FabricatorSchemas.BigLostShipmentCase.UpdateColumns
            .map((c, i) => `"${c}" = $${i + 1}`).join(", ");
        let values: any = item.getPostgresUpdateValues();
        const query = `UPDATE ${PostgresInfo.Tables.LostShipmentCases} SET ${updateVals}
            WHERE "uniqueId" = $${FabricatorSchemas.BigLostShipmentCase.UpdateColumns.length + 1};`;
        await client.query(query, values);
    } catch (e) {
        Logger.error("[updateLostShipmentCase] Error: " + e.message);
        throw e;
    } finally {
        client.release();
    }
}
```

### 4f. Delete (test cleanup)

```typescript
export async function deleteLostShipmentCase(uniqueId: string) {
    const client = await PostgresDatabase.Postgres.getFabricatorClient();
    try {
        await client.query(
            `DELETE FROM ${PostgresInfo.Tables.LostShipmentCases} WHERE "uniqueId" = $1;`, [uniqueId]
        );
    } catch (e) {
        Logger.error("[deleteLostShipmentCase] Error: " + e.message);
    }
    client.release();
}
```

---

## 5. API Layer

**File to create:** `api/src/fabricator/LostShipmentApi.ts`

```typescript
import { Auth } from "@bigscreen/auth";
import { Logger } from "@bigscreen/lib";
import { FabricatorSchemas } from "./FabricatorSchemas";
import { FabricatorDatabase } from "./FabricatorDatabase";

export class LostShipmentApi {

    /** GET /admin/lost-shipments */
    public static async getCases(req: Auth.AuthRequest, res: Auth.AuthResponse) {
        try {
            const params: FabricatorDatabase.GetLostShipmentCasesParams = {};
            if (req.query["claimStatus"]) params.claimStatus = req.query["claimStatus"] as string;
            if (req.query["bigShipmentId"]) params.bigShipmentId = req.query["bigShipmentId"] as string;
            if (req.query["trackingNumber"]) params.trackingNumber = req.query["trackingNumber"] as string;
            if (req.query["carrier"]) params.carrier = req.query["carrier"] as string;
            if (req.query["minCreatedAt"]) params.minCreatedAt = parseInt(req.query["minCreatedAt"] as string);
            if (req.query["maxCreatedAt"]) params.maxCreatedAt = parseInt(req.query["maxCreatedAt"] as string);

            let limit: number | undefined;
            let offset: number | undefined;
            if (req.query["limit"]) limit = parseInt(req.query["limit"] as string);
            if (req.query["offset"]) offset = parseInt(req.query["offset"] as string);

            const result = await FabricatorDatabase.getLostShipmentCases(params, limit, offset);
            return res.json({ items: result.items, count: result.count });
        } catch (error) {
            Logger.error("[LostShipmentApi.getCases] Error:", error);
            return res.status(500).json({ error: error.message });
        }
    }

    /** GET /admin/lost-shipments/:id */
    public static async getCase(req: Auth.AuthRequest, res: Auth.AuthResponse) {
        try {
            const caseItem = await FabricatorDatabase.getLostShipmentCaseById(req.params.id);
            if (!caseItem) return res.status(404).json({ error: "Lost shipment case not found" });
            return res.json(caseItem);
        } catch (error) {
            Logger.error("[LostShipmentApi.getCase] Error:", error);
            return res.status(500).json({ error: error.message });
        }
    }

    /**
     * POST /admin/lost-shipments
     * [R1] Checks for existing non-resolved case on same shipment before creating.
     * [R5] Uses BigShipment helper methods for tracking/carrier extraction.
     */
    public static async createCase(req: Auth.AuthRequest, res: Auth.AuthResponse) {
        try {
            const { bigShipmentId, trackingNumber, carrier, customerEmail, csTicketId, csTicketUrl, notes } = req.body;
            if (!bigShipmentId) return res.status(400).json({ error: "bigShipmentId is required" });

            const shipment = await FabricatorDatabase.getBigShipment({ id: bigShipmentId });
            if (!shipment) return res.status(404).json({ error: "Shipment not found" });

            // [R1] Duplicate check
            const existing = await FabricatorDatabase.getLostShipmentCaseByShipmentId(bigShipmentId);
            if (existing && existing.claimStatus !== FabricatorSchemas.LostShipmentCaseStatus.Resolved) {
                return res.status(409).json({
                    error: "An open lost shipment case already exists for this shipment",
                    existingCaseId: existing.uniqueId
                });
            }

            // [R5] Use helper methods
            const caseItem = new FabricatorSchemas.BigLostShipmentCase({
                bigShipmentId,
                trackingNumber: trackingNumber || shipment.getTrackingNumber(),
                carrier: carrier || shipment.getCarrier(),
                customerEmail: customerEmail || "",
                csTicketId: csTicketId || "",
                csTicketUrl: csTicketUrl || "",
                claimStatus: FabricatorSchemas.LostShipmentCaseStatus.Reported,
                notes: notes || "",
                reportedAt: Date.now(),
                creatorBigscreenAccountId: req.verifiedAccessToken.bigscreenAccountId
            });

            const created = await FabricatorDatabase.createLostShipmentCase(caseItem);
            return res.json(created);
        } catch (error) {
            Logger.error("[LostShipmentApi.createCase] Error:", error);
            return res.status(500).json({ error: error.message });
        }
    }

    /**
     * PUT /admin/lost-shipments/:id
     * [R2] Logs every status change in statusHistory.
     */
    public static async updateCase(req: Auth.AuthRequest, res: Auth.AuthResponse) {
        try {
            const caseItem = await FabricatorDatabase.getLostShipmentCaseById(req.params.id);
            if (!caseItem) return res.status(404).json({ error: "Lost shipment case not found" });

            const {
                claimStatus, notes, claimAmount, claimCurrency,
                carrierClaimId, csTicketId, csTicketUrl, customerEmail
            } = req.body;

            if (claimStatus) {
                const validStatuses = Object.values(FabricatorSchemas.LostShipmentCaseStatus);
                if (!validStatuses.includes(claimStatus)) {
                    return res.status(400).json({
                        error: `Invalid claimStatus. Must be one of: ${validStatuses.join(", ")}`
                    });
                }

                // [R2] Log transition
                if (claimStatus !== caseItem.claimStatus) {
                    caseItem.addStatusChange(
                        caseItem.claimStatus,
                        claimStatus,
                        req.verifiedAccessToken.bigscreenAccountId
                    );
                }

                caseItem.claimStatus = claimStatus;

                // Auto-timestamps
                if (claimStatus === FabricatorSchemas.LostShipmentCaseStatus.ClaimFiled && !caseItem.claimFiledAt) {
                    caseItem.claimFiledAt = Date.now();
                }
                if (claimStatus === FabricatorSchemas.LostShipmentCaseStatus.Resolved && !caseItem.resolvedAt) {
                    caseItem.resolvedAt = Date.now();
                }
            }

            if (notes !== undefined) caseItem.notes = notes;
            if (claimAmount !== undefined) caseItem.claimAmount = claimAmount;
            if (claimCurrency) caseItem.claimCurrency = claimCurrency;
            if (carrierClaimId !== undefined) caseItem.carrierClaimId = carrierClaimId;
            if (csTicketId !== undefined) caseItem.csTicketId = csTicketId;
            if (csTicketUrl !== undefined) caseItem.csTicketUrl = csTicketUrl;
            if (customerEmail !== undefined) caseItem.customerEmail = customerEmail;

            await FabricatorDatabase.updateLostShipmentCase(caseItem);
            return res.json(caseItem);
        } catch (error) {
            Logger.error("[LostShipmentApi.updateCase] Error:", error);
            return res.status(500).json({ error: error.message });
        }
    }
}
```

---

## 6. Route Registration

**File:** `apps/admin_api/admin_api.ts`

Import (after line 19):
```typescript
import { LostShipmentApi } from "@bigscreen/api/src/fabricator/LostShipmentApi";
```

Routes (after line 1063):
```typescript
// ─── Lost Shipments ─────────────────────────────────────────────────
api.get("/admin/lost-shipments",
    AuthApi.getAccessPolicyHandler([AuthSchemas.AccessPolicy.Admin, AuthSchemas.AccessPolicy.Inventory]),
    LostShipmentApi.getCases);
api.get("/admin/lost-shipments/:id",
    AuthApi.getAccessPolicyHandler([AuthSchemas.AccessPolicy.Admin, AuthSchemas.AccessPolicy.Inventory]),
    LostShipmentApi.getCase);
api.post("/admin/lost-shipments",
    AuthApi.getAccessPolicyHandler([AuthSchemas.AccessPolicy.Admin, AuthSchemas.AccessPolicy.Inventory]),
    LostShipmentApi.createCase);
api.put("/admin/lost-shipments/:id",
    AuthApi.getAccessPolicyHandler([AuthSchemas.AccessPolicy.Admin, AuthSchemas.AccessPolicy.Inventory]),
    LostShipmentApi.updateCase);
```

---

## 7. Shipment Integration

### 7a. Add `ReportLost` to `BigShipmentAction`

**File:** `api/src/fabricator/FabricatorSchemas.ts` (line 3059)

```typescript
/** Mark a shipment as lost and create a lost shipment case. */
ReportLost = "ReportLost"
```

### 7b. Handle in `updateBigShipmentInternal`

**File:** `api/src/fabricator/ShippingAdminApi.ts` (~line 2544)

```typescript
if (params.action === FabricatorSchemas.BigShipmentAction.ReportLost) {
    return ShippingAdminApi.handleReportLost(req, bigShipment);
}
```

### 7c. Handler `[R1, R5]`

```typescript
private static async handleReportLost(
    req: Auth.AuthRequest,
    bigShipment: FabricatorSchemas.BigShipment
): Promise<FabricatorSchemas.BigShipment> {
    // [R1] Check for existing case
    const existingCase = await FabricatorDatabase.getLostShipmentCaseByShipmentId(bigShipment.id);
    if (existingCase && existingCase.claimStatus !== FabricatorSchemas.LostShipmentCaseStatus.Resolved) {
        throw new FabricatorApiError(
            `An open lost shipment case already exists for shipment ${bigShipment.id}`,
            FabricatorApiErrorCode.BigShipmentInvalidState
        );
    }

    // [R5] Use helper methods
    const caseItem = new FabricatorSchemas.BigLostShipmentCase({
        bigShipmentId: bigShipment.id,
        trackingNumber: bigShipment.getTrackingNumber(),
        carrier: bigShipment.getCarrier(),
        claimStatus: FabricatorSchemas.LostShipmentCaseStatus.Reported,
        reportedAt: Date.now(),
        creatorBigscreenAccountId: req.verifiedAccessToken.bigscreenAccountId
    });

    await FabricatorDatabase.createLostShipmentCase(caseItem);
    bigShipment.addHistory("Shipment reported as lost — case created", req.currentAccount);
    await FabricatorDatabase.updateBigShipment(bigShipment);
    return bigShipment;
}
```

### 7d. Add lost case to extended shipment response `[R3, R8]`

**File:** `api/src/fabricator/ShippingAdminApi.ts` — in `getBigShipmentExtended`:

```typescript
// [R3] Attach lost case if one exists
// [R8] Wrapped in try/catch — if table doesn't exist, return null gracefully
let lostShipmentCase = null;
try {
    lostShipmentCase = await FabricatorDatabase.getLostShipmentCaseByShipmentId(bigShipment.id);
} catch (e) {
    Logger.warn("[getBigShipmentExtended] Could not fetch lost case: " + e.message);
}

// Include in response
return res.json({ ...bigShipment, lostShipmentCase });
```

---

## 8. Frontend

### 8a. Navigation link

**File:** `webapps/src/components/BigLogistics/BigShipperWrapper.jsx` (line 19):

```javascript
{ icon: "exclamation triangle", title: "Lost Shipments", href: "/shipper/lost" },
```

### 8b. `LostShipmentsPage.jsx` — list + inline edit modal

**File to create:** `webapps/src/components/BigLogistics/LostShipmentsPage.jsx`

Contains:
- Filter bar (claimStatus dropdown)
- Table listing all cases (tracking#, carrier, status, CS ticket, claim amount, reported date)
- Click row → opens inline **Modal** for editing (status, notes, CS ticket info, claim info)
- "New Case" button → opens create modal (requires shipment ID)

Follows `BigShipmentList.jsx` pattern: Semantic UI `Table` + `Form` + `Modal`, `superagent` for API calls, `BigShipperWrapper` as page chrome.

### 8c. "Report Lost" button on shipment detail

**File:** `webapps/src/components/BigLogistics/BigShipment.jsx`

Add in the actions section (near Cancel/ForceShipped buttons). Use `ApiButtonModal` pattern:

```jsx
<ApiButtonModal
    color="orange"
    size="small"
    text="Report Lost"
    modalIcon="exclamation triangle"
    icon="exclamation triangle"
    modalButtonText="Yes, Report as Lost"
    onConfirm={this.onReportLost.bind(this)}>
    This will create a lost shipment case for this shipment.
</ApiButtonModal>
```

Handler:
```javascript
async onReportLost() {
    await superagent.put(`/api/admin/shipper/shipment/${this.state.bigShipment.id}`)
        .send({ action: "ReportLost" })
        .set(getAccessTokenHeader()).set(getApiKeyHeader());
    await this.reload();
}
```

Also show a `Label` if `lostShipmentCase` is present on the extended response `[R3]`:
```jsx
{this.state.bigShipment.lostShipmentCase && (
    <Label color="orange" icon="exclamation triangle">
        Lost — {this.state.bigShipment.lostShipmentCase.claimStatus}
    </Label>
)}
```

### 8d. Route in `App.jsx`

**File:** `webapps/arda/app/App.jsx`

Import + route (in shipper section):
```jsx
import LostShipmentsPage from '../../src/components/BigLogistics/LostShipmentsPage.jsx';

<Route exact path="/shipper/lost" render={(props) => ( <LostShipmentsPage {...props} /> )} />
```

Note: no `/shipper/lost/:id` route needed — detail editing is via modal.

---

## 9. Testing

**File to create:** `tests/fabricator/155.LostShipmentApi.ts`

Full coverage for all 26 codepaths `[R7]`:

```
describe("Lost Shipment API Tests")
  before: create test account, create test BigOrder + BigShipment (follow 029.BigShipperFlow.ts)
  after: cleanup all created records

  describe("Database CRUD")
    T21: create lost shipment case
    T22: get by uniqueId
    T23: get by varchar id
    T24: list with filters
    T25: update case
    T26: delete case (cleanup)

  describe("POST /admin/lost-shipments")
    T5:  create case (happy path)
    T1:  missing bigShipmentId → 400
    T2:  shipment not found → 404
    T3:  duplicate open case → 409
    T4:  auto-fill tracking/carrier from label

  describe("GET /admin/lost-shipments")
    T6:  list all cases
    T7:  filter by claimStatus
    T8:  filter by bigShipmentId

  describe("GET /admin/lost-shipments/:id")
    T9:  not found → 404
    T10: found → 200

  describe("PUT /admin/lost-shipments/:id")
    T11: not found → 404
    T12: invalid status → 400
    T13: status → ClaimFiled sets claimFiledAt
    T14: status → Resolved sets resolvedAt
    T15: statusHistory logged on transition
    T16: update success (happy path)

  describe("ReportLost Shipment Action")
    T17: PUT /admin/shipper/shipment/:id { action: "ReportLost" } creates case
    T18: duplicate via action → error
    T19: shipment history entry added

  describe("Extended Shipment Response")
    T20: GET /admin/shipper/shipment/:id includes lostShipmentCase
```

Run with:
```bash
cd /c/Bigscreen/cloud/tests
ts-mocha --bail --exit --timeout=500000 fabricator/155.LostShipmentApi.ts
```

---

## 10. Verification

1. Run `beyond_db_setup` → verify table + enum created
2. `yarn dev` → no compilation errors
3. Run test suite → all 26 tests pass
4. Navigate to `/shipper/lost` → list page renders
5. Go to shipment → click "Report Lost" → case created, history updated
6. Edit case via modal → status/notes/claim info saved
7. Refresh shipment page → orange "Lost" label visible `[R3]`

### Checklist

- [ ] `PostgresInfo` entries added
- [ ] `beyond_db_setup.ts` creates enum + table + indexes + trigger
- [ ] `BigLostShipmentCase` class with `JsonbColumns` + `addStatusChange()` `[R2, R6]`
- [ ] `getTrackingNumber()` / `getCarrier()` helpers on `BigShipment` `[R5]`
- [ ] `ReportLost` in `BigShipmentAction` enum
- [ ] CRUD methods in `FabricatorDatabase.ts` (using `SearchQuery` for equality filters) `[R4]`
- [ ] `LostShipmentApi.ts` with duplicate check `[R1]`
- [ ] 4 routes in `admin_api.ts`
- [ ] `handleReportLost` in `ShippingAdminApi.ts`
- [ ] `lostShipmentCase` in `getBigShipmentExtended` response `[R3]`
- [ ] `LostShipmentsPage.jsx` (list + modal)
- [ ] "Report Lost" button + label in `BigShipment.jsx`
- [ ] Nav link in `BigShipperWrapper.jsx`
- [ ] Route in `App.jsx`
- [ ] All 26 tests passing `[R7]`
