# 2-Phase Opportunity Assignment Flow

**Date:** 2026-04-15  
**Status:** Design spec (ready for implementation)  
**Amends:** eve-market-calc-spec.md, eve-market-calc-ui-spec.md

---

## 1. Overview

Replace the current one-at-a-time "Assign to Me" button on the Opportunities page with a deliberate 2-phase workflow:

- **Phase 1 (Browse & Cart):** Browse opportunities, add items to a server-side cart, review profitability history for carted items before committing.
- **Phase 2 (Assign):** Drag-drop opportunities from the cart onto people in a people list, then batch-confirm all assignments in one POST.

This mirrors spreadsheet-style planning: you gather candidates first, evaluate trends, then commit assignments all at once.

---

## 2. Concepts

### 2.1 People

A per-user list of corp members you regularly assign work to. People are **not** EVE SSO accounts — they're freeform names the logged-in user creates (e.g., "Alice", "Bob's alt"). Each person tracks how many active assignments they currently hold.

### 2.2 Cart

A server-side holding area for opportunities the user is considering. Cart items are scoped to the authenticated user (`user_id` from EVE SSO). The cart persists across browser sessions (it lives in the DB, not localStorage). Each cart entry references an `item_id` + `region` pair from the opportunities list.

When the cart is fetched via `GET /cart`, the backend enriches each entry with:
- Current item name (from SDE)
- Current margin (latest `build_costs` row)
- 7-day profitability history (hourly margin data points from `build_costs`)

This lets the user see trend lines before committing.

### 2.3 Batch Assignment

Phase 2 produces a list of `(item_id, region, person_id)` tuples. These are submitted as a single `POST /assignments/batch`. The backend inserts all rows in a transaction — all succeed or all fail. On success the cart is automatically cleared.

---

## 3. User Flow (Step by Step)

### 3.1 Prerequisites

User must be authenticated via EVE SSO. The `user_id` (EVE character ID) is extracted from the JWT by the Koa proxy and forwarded to the Go API as the `X-Member-ID` header (same pattern as the existing `/dashboard` endpoint).

### 3.2 People Management (Persistent, Any Time)

Accessible from a dedicated section on the new Assignments page or as a slide-out panel.

1. User clicks **"Add Person"** → text input for EVE character name (or partial name) → autocomplete search via ESI `/characters/{character_id}/search` (using the logged-in character's ID).
2. User selects a character from the autocomplete results. The API retrieves that character's ID and stores it in the `people` table via `POST /people` with `{ "character_id": 12345 }`.
3. Backend creates the person scoped to the user's `user_id` and stores the EVE `character_id`. Returns `person_id` and the character's name/portrait.
4. People list displays each person with their character name, portrait (ESI image), and current `assignment_count` (computed from the `assignments` table via a `COUNT` join).
5. User can delete a person via `DELETE /people/{person_id}`. This also cascades deletes to any assignments referencing that `person_id`.

### 3.3 Phase 1: Browse & Add to Cart

The Opportunities page (`/opportunities`) keeps its existing filter/sort/search controls. Changes:

1. **Remove** the "Assign to Me" button from each row.
2. **Add** a checkbox column (leftmost) on each opportunity row.
3. **Add** a "select all on page" checkbox in the table header.
4. **Add** a floating action bar at the bottom of the page (visible when ≥1 item is checked):
   - Shows: `"X items selected"`
   - Buttons: **"Add to Cart"** (primary), **"Clear Selection"** (text)
5. Clicking **"Add to Cart"** calls `POST /cart/batch` with all selected items.
6. After adding, checkboxes clear and a snackbar confirms `"X items added to cart"`.

**Cart indicator:** The sidebar nav item for Assignments gets a badge showing the cart count (fetched from `GET /cart/count`).

**Already-in-cart indicator:** Opportunity rows for items already in the cart show a cart icon overlay and a disabled checkbox with tooltip "Already in cart".

### 3.4 Phase 1 (continued): Review Cart

The cart is displayed on the **Cart** tab within the Assignments page (tabs: "My Assignments" | "Cart (N)" | "People").

Cart view shows a list of carted items, each rendered as a card:

```
┌─────────────────────────────────────────────────┐
│ [Icon] Rifter                        Forge      │
│ Current Margin: 31.2%  ▲ trending up            │
│ ┌─────────────────────────────────────────────┐ │
│ │  [7-day margin sparkline chart]              │ │
│ │  echarts mini line chart, ~168 data points  │ │
│ └─────────────────────────────────────────────┘ │
│                                    [Remove] btn │
└─────────────────────────────────────────────────┘
```

- **Sparkline chart:** echarts line chart showing margin over the last 7 days (hourly buckets). Data comes from `profitability_history` in the `GET /cart` response.
- **Trend indicator:** Compare latest margin to 24h-ago margin. Show ▲ (green) if up, ▼ (red) if down, ─ (grey) if flat (±1%).
- **Margin warning:** If current margin has dropped below 20%, show amber border and "⚠️ margin dropped below 20%" — still allow assignment (user decides).
- **Remove button:** Calls `DELETE /cart/{item_id}/{region}`, removes the card with an exit animation.
- **"Assign Items" button:** Top of cart tab, disabled if cart empty or people list empty.
- **"Clear Cart" button:** Secondary, with confirmation dialog.

### 3.5 Phase 2: Assign via Drag-Drop

User clicks **"Assign Items"** button. This opens a full-screen MUI `Dialog`.

**Layout:**

```
┌──────────────────────────┬──────────────────────────┐
│   Cart Items (Left)      │   People (Right)         │
│                          │                          │
│ ┌──────────────────────┐ │ ┌──────────────────────┐ │
│ │ [drag] Rifter  Forge │ │ │ Alice (3 assigned)   │ │
│ └──────────────────────┘ │ │  • Rifter (Forge) ←  │ │
│ ┌──────────────────────┐ │ │                      │ │
│ │ [drag] Moa     Forge │ │ └──────────────────────┘ │
│ └──────────────────────┘ │ ┌──────────────────────┐ │
│ ┌──────────────────────┐ │ │ Bob (1 assigned)     │ │
│ │ [drag] Thorax  Sinq  │ │ │                      │ │
│ └──────────────────────┘ │ └──────────────────────┘ │
│                          │                          │
│                          │ [Confirm Assignments]    │
│                          │ [Cancel]                 │
└──────────────────────────┴──────────────────────────┘
```

**Behavior:**

1. **Drag** an item from the left panel → **drop** onto a person card on the right.
2. The item moves from the left list to appear as a pending sub-item under that person (italic or pending icon styling).
3. Items can be dragged back to the left panel to un-assign before confirming.
4. The assignment count in parentheses updates in real-time (existing DB count + pending count).
5. **Confirm Assignments** button is disabled until at least one item is assigned to a person.
6. Clicking **Confirm** calls `POST /assignments/batch` with all `(item_id, region, person_id)` tuples.
7. **On success:** Cart cleared server-side, dialog closes, snackbar shows `"X assignments created"`, Assignments tab refreshes.
8. **On failure:** Revert all optimistic state, show error alert with the backend message.

**No per-drag API calls** — all assignment state is local until Confirm.

**Drag-drop library:** `@dnd-kit/core` + `@dnd-kit/sortable` (lightweight, React-native, works with MUI). Do NOT use `react-beautiful-dnd` (deprecated).

### 3.6 Post-Assign

After batch confirmation:
- Cart is empty (server cleared it).
- Assignments are visible under each person in the "My Assignments" tab.
- The Assignments table now groups by person (accordion/expandable rows).

---

## 4. Data Model

### 4.1 New Tables

```sql
-- People list (per-user, keyed by EVE character ID)
CREATE TABLE people (
  person_id SERIAL PRIMARY KEY,
  user_id TEXT NOT NULL,           -- EVE character ID of the list owner (string, from SSO)
  character_id INT NOT NULL,       -- EVE character ID of the person being assigned to
  character_name TEXT NOT NULL,    -- Character name (cached from ESI at add time)
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, character_id)
);

CREATE INDEX idx_people_user ON people (user_id);
CREATE INDEX idx_people_character ON people (character_id);

-- Cart (per-user, server-side)
CREATE TABLE cart (
  cart_id BIGSERIAL PRIMARY KEY,
  user_id TEXT NOT NULL,           -- EVE character ID
  item_id INT NOT NULL,
  region TEXT NOT NULL,
  added_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, item_id, region)
);

CREATE INDEX idx_cart_user ON cart (user_id);
```

### 4.2 Modified Tables

The existing `assignments` table needs a `person_id` foreign key to replace `corp_member_id`:

```sql
ALTER TABLE assignments ADD COLUMN person_id INT REFERENCES people(person_id) ON DELETE CASCADE;
CREATE INDEX idx_assignments_person ON assignments (person_id);
```

**Migration strategy:** During transition, new assignments populate `person_id`. Old assignments keep `corp_member_id`. The UI reads `person_id` first, falls back to `corp_member_id` for legacy rows. Once old assignments age out, drop `corp_member_id`.

### 4.3 Existing Tables Used (Read-Only)

- `build_costs` — profitability history for cart item enrichment
- `pruned_list` — feeds the opportunities list (not directly used by 2-phase)
- `sde_blueprints` / `eve_types` — item name resolution via SDE queries

---

## 5. API Surface

All endpoints require authentication. The Go API receives `X-Member-ID` (user's EVE character ID as string) from the Koa proxy.

### 5.1 People Management

#### `POST /people`

Create a person in the user's list by EVE character ID.

**Request:**
```json
{ "character_id": 12345 }
```

**Response (201):**
```json
{
  "person_id": 42,
  "character_id": 12345,
  "character_name": "Alice Starweaver",
  "character_portrait": "https://images.evetech.net/characters/12345/portrait?size=64",
  "assignment_count": 0
}
```

**Errors:**
- `409 Conflict` — character already in this user's people list
- `400 Bad Request` — character_id is invalid or user is trying to add their own logged-in character
- `404 Not Found` — character_id doesn't exist in EVE

**Implementation:**
1. Verify `character_id` exists and is not the logged-in user's own `user_id`.
2. Fetch character name + portrait URL from EVE ESI (`/characters/{character_id}` endpoint).
3. Insert into `people` table (scoped to user and character_id).
4. Return person_id, character metadata, assignment_count.

```sql
INSERT INTO people (user_id, character_id, character_name)
VALUES ($1, $2, $3)
RETURNING person_id, character_id, character_name;
```

#### `GET /people`

List all people for the authenticated user, with assignment counts and character metadata.

**Response (200):**
```json
[
  {
    "person_id": 42,
    "character_id": 12345,
    "character_name": "Alice Starweaver",
    "character_portrait": "https://images.evetech.net/characters/12345/portrait?size=64",
    "assignment_count": 3
  },
  {
    "person_id": 43,
    "character_id": 67890,
    "character_name": "Bob Nullsecminer",
    "character_portrait": "https://images.evetech.net/characters/67890/portrait?size=64",
    "assignment_count": 1
  }
]
```

**Implementation:**
```sql
SELECT p.person_id, p.character_id, p.character_name,
       COUNT(a.id) AS assignment_count
FROM people p
LEFT JOIN assignments a ON a.person_id = p.person_id
WHERE p.user_id = $1
GROUP BY p.person_id, p.character_id, p.character_name
ORDER BY p.character_name ASC;
```

Character portrait URL is constructed as `https://images.evetech.net/characters/{character_id}/portrait?size=64`.

#### `DELETE /people/{person_id}`

Delete a person and cascade to their assignments.

**Response:** `204 No Content`

**Authorization:** Verify `person_id` belongs to the requesting `user_id`:
```sql
DELETE FROM people WHERE person_id = $1 AND user_id = $2;
```
If 0 rows affected → `404 Not Found`.

**UI confirmation:** `"Delete Alice? This will also remove their 3 assignments."`

---

### 5.2 Cart

#### `POST /cart`

Add one item to the cart.

**Request:**
```json
{ "item_id": 587, "region": "Forge" }
```

**Response (201):**
```json
{ "item_id": 587, "region": "Forge", "added_at": "2026-04-15T18:00:00Z" }
```

**Idempotent:** If already in cart, return `200` with existing entry.

```sql
INSERT INTO cart (user_id, item_id, region)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, item_id, region) DO NOTHING
RETURNING cart_id, item_id, region, added_at;
```

#### `POST /cart/batch`

Add multiple items at once (avoids N round-trips from the Opportunities page).

**Request:**
```json
{
  "items": [
    { "item_id": 587, "region": "Forge" },
    { "item_id": 11379, "region": "Sinq Laison" }
  ]
}
```

**Response (201):**
```json
{ "added": 2, "duplicates": 0 }
```

**Implementation:** Loop inserts with `ON CONFLICT DO NOTHING` in a single transaction.

#### `GET /cart`

Fetch all cart items with enriched profitability data.

**Response (200):**
```json
{
  "items": [
    {
      "item_id": 587,
      "item_name": "Rifter",
      "region": "Forge",
      "current_margin": 0.312,
      "current_build_cost": 2450000,
      "current_sell_price": 3100000,
      "trend": "up",
      "profitability_history": [
        { "time": "2026-04-15T12:00:00Z", "margin": 0.305, "build_cost": 2480000, "sell_price": 3100000 },
        { "time": "2026-04-15T11:00:00Z", "margin": 0.318, "build_cost": 2430000, "sell_price": 3110000 }
      ]
    }
  ],
  "count": 1
}
```

**Enrichment per cart item:**

1. Fetch cart rows:
   ```sql
   SELECT item_id, region, added_at FROM cart WHERE user_id = $1 ORDER BY added_at DESC;
   ```

2. Latest margin:
   ```sql
   SELECT total_build_cost, sell_price, margin
   FROM build_costs
   WHERE item_id = $1 AND region = $2
   ORDER BY time DESC LIMIT 1;
   ```

3. 7-day history (hourly buckets):
   ```sql
   SELECT
     time_bucket('1 hour', time) AS bucket,
     AVG(margin) AS margin,
     AVG(total_build_cost) AS build_cost,
     AVG(sell_price) AS sell_price
   FROM build_costs
   WHERE item_id = $1 AND region = $2
     AND time >= NOW() - INTERVAL '7 days'
   GROUP BY bucket
   ORDER BY bucket ASC;
   ```

4. Compute trend: compare latest margin to ~24h-ago margin. `"up"` if ≥1% higher, `"down"` if ≥1% lower, `"flat"` otherwise.

5. Resolve `item_name` from SDE (`sdeQ.GetTypeName`).

**Performance note:** For carts with >20 items, consider reducing history to 3 days or `3h` buckets.

#### `GET /cart/count`

Lightweight endpoint for sidebar badge.

**Response (200):**
```json
{ "count": 5 }
```

#### `DELETE /cart/{item_id}/{region}`

Remove a single item from the cart.

**Response:** `204 No Content`

#### `DELETE /cart`

Clear the entire cart.

**Response:** `204 No Content`

---

### 5.3 Batch Assignments (Phase 2)

#### `POST /assignments/batch`

Create multiple assignments in a single transaction and clear the cart.

**Request:**
```json
{
  "assignments": [
    { "item_id": 587, "region": "Forge", "person_id": 42 },
    { "item_id": 11379, "region": "Sinq Laison", "person_id": 43 },
    { "item_id": 587, "region": "Forge", "person_id": 43 }
  ]
}
```

**Response (201):**
```json
{ "created": 3 }
```

**Errors:**
- `400 Bad Request` — empty list, or a `person_id` that doesn't belong to the user
- `409 Conflict` — duplicate assignment (same item+region+person) already exists

**Implementation:**
```sql
BEGIN;

-- Validate all person_ids belong to this user
SELECT person_id FROM people WHERE user_id = $1 AND person_id = ANY($2::int[]);
-- If count != distinct person_ids in request → 400

-- Insert assignments (idempotent)
INSERT INTO assignments (person_id, item_id, region)
VALUES ($person_id, $item_id, $region)
ON CONFLICT (person_id, item_id, region) DO NOTHING;

-- Clear cart
DELETE FROM cart WHERE user_id = $1;

COMMIT;
```

The `created` count reflects actual new rows inserted.

**Required constraint:**
```sql
CREATE UNIQUE INDEX uniq_person_assignment
  ON assignments (person_id, item_id, region)
  WHERE person_id IS NOT NULL;
```

#### `GET /people/{person_id}/assignments`

Fetch assignments for a specific person, with current margin data.

**Response (200):**
```json
{
  "person_id": 42,
  "person_name": "Alice",
  "assignments": [
    {
      "assignment_id": 17,
      "item_id": 587,
      "item_name": "Rifter",
      "region": "Forge",
      "assigned_at": "2026-04-15T18:30:00Z",
      "current_margin": 0.312
    }
  ]
}
```

**Implementation:**
```sql
SELECT a.id AS assignment_id, a.item_id, a.region, a.assigned_at,
       bc.margin AS current_margin
FROM assignments a
LEFT JOIN LATERAL (
  SELECT margin FROM build_costs
  WHERE item_id = a.item_id AND region = a.region
  ORDER BY time DESC LIMIT 1
) bc ON true
WHERE a.person_id = $1;
```

Item names resolved via SDE. Authorization: verify the person belongs to the requesting user.

#### `DELETE /assignments/{assignment_id}`

Remove a single assignment.

**Response:** `204 No Content`

**Authorization:** Join through `people` to verify the assignment's person belongs to the requesting user:
```sql
DELETE FROM assignments a
USING people p
WHERE a.id = $1 AND a.person_id = p.person_id AND p.user_id = $2;
```

---

## 6. Frontend Changes

### 6.1 New Dependencies

```json
{
  "@dnd-kit/core": "^6.x",
  "@dnd-kit/sortable": "^8.x",
  "@dnd-kit/utilities": "^3.x"
}
```

### 6.2 New Types (`src/types/`)

```typescript
// src/types/person.ts
export interface Person {
  person_id: number;
  character_id: number;
  character_name: string;
  character_portrait: string;  // ESI URL
  assignment_count: number;
}

// src/types/cart.ts
export interface CartItem {
  item_id: number;
  item_name: string;
  region: string;
  current_margin: number;
  current_build_cost: number;
  current_sell_price: number;
  trend: 'up' | 'down' | 'flat';
  profitability_history: ProfitabilityPoint[];
}

export interface ProfitabilityPoint {
  time: string;
  margin: number;
  build_cost: number;
  sell_price: number;
}

export interface CartResponse {
  items: CartItem[];
  count: number;
}

// src/types/assignment.ts (updated)
export interface Assignment {
  assignment_id?: number;
  item_id: number;
  item_name: string;
  region: string;
  assigned_at: string;
  current_margin?: number;
  person_id?: number;
  person_name?: string;
}

export interface BatchAssignmentRequest {
  assignments: Array<{
    item_id: number;
    region: string;
    person_id: number;
  }>;
}
```

### 6.3 New Hooks (`src/hooks/`)

- **`usePeople.ts`** — CRUD for people list. Fetches `GET /people` on mount, exposes `addPerson`, `deletePerson`, `refetch`.
- **`useCart.ts`** — Fetches `GET /cart` (full) and `GET /cart/count` (for badge). Exposes `addToCart` (`POST /cart/batch`), `removeFromCart`, `clearCart`.
- **`useBatchAssign.ts`** — Takes a `Map<number, PendingAssignment[]>` from drag-drop state, formats the `POST /assignments/batch` payload, handles success/error callbacks.

### 6.4 Opportunities Page Changes

- **Remove** the "Assign to Me" button and its confirmation dialog.
- **Add** checkbox column (MUI `Checkbox`) as the first `TableCell`.
- **Add** "select all on page" checkbox in `TableHead`.
- **Add** floating bottom bar (MUI `AppBar` with `position="fixed"`, `bottom: 0`, `zIndex: theme.zIndex.appBar + 1`) visible when selection count > 0:
  ```
  ┌──────────────────────────────────────────────────────────┐
  │  ✓ 4 items selected     [Clear Selection]  [Add to Cart]│
  └──────────────────────────────────────────────────────────┘
  ```
- **Selection state:** `Set<string>` keyed by `"${item_id}-${region}"`.
- **Already-in-cart:** On mount, fetch `GET /cart/count` (or full cart if needed). For items in cart, show a small cart icon in the row and disable the checkbox with tooltip "Already in cart".

### 6.5 Assignments Page Refactor

Refactor into a tabbed layout using MUI `Tabs`:

```
┌─────────────────────────────────────────────────┐
│ [My Assignments] [Cart (5)] [People]            │
├─────────────────────────────────────────────────┤
│  (tab content)                                  │
└─────────────────────────────────────────────────┘
```

**Tab: My Assignments**
- Table grouped by person (MUI `Accordion` or expandable rows).
- Each person section: name, total assigned count, assignment rows underneath.
- Unassign button per row (calls `DELETE /assignments/{assignment_id}`).
- Legacy assignments (with `corp_member_id` but no `person_id`) shown in an "Unassigned" group.

**Tab: Cart (N)**
- Badge count in tab label from `GET /cart/count`.
- List of `CartItem` cards with sparkline charts (see §3.4).
- **"Assign Items"** button at top (disabled if cart empty or people list empty; tooltip explains why).
- **"Clear Cart"** button (secondary, with confirmation dialog).

**Tab: People**
- Simple list with add/delete controls.
- Add: text field + "Add" button inline.
- Each row: person name, assignment count badge, delete button (with cascade confirmation dialog).

### 6.6 New Component: `AssignmentPanel.tsx`

Full-screen MUI `Dialog` for drag-drop assignment (§3.5).

**State management:**
```typescript
interface PendingAssignment {
  item_id: number;
  region: string;
  item_name: string;
}

// Map from person_id → list of items dragged onto them
const [pending, setPending] = useState<Map<number, PendingAssignment[]>>(new Map());

// Items still in the unassigned pool
const unassigned = cartItems.filter(
  item => !Array.from(pending.values()).flat().some(
    p => p.item_id === item.item_id && p.region === item.region
  )
);
```

**dnd-kit setup:**
- `DndContext` wrapping the panel.
- Left panel: cart items as draggable (`useDraggable` or `useSortable`).
- Right panel: each person card is a droppable target (`useDroppable`).
- `onDragEnd`: if dropped on a person, move item from unassigned to `pending[person_id]`. If dropped back on left panel, move back.
- Items under a person are also draggable (can be returned to the pool).

### 6.7 New Component: `MarginSparkline.tsx`

Tiny echarts line chart for cart item cards.

```typescript
interface MarginSparklineProps {
  data: ProfitabilityPoint[];
  height?: number;  // default 60
  width?: string;   // default '100%'
}
```

- Single line (margin over time).
- No axis labels, no legend — just the line and subtle grid.
- Color: green if trend is up, red if down, grey if flat.
- Tooltip on hover showing date + margin %.
- Uses `echarts-for-react` (already in the project).

---

## 7. Go API: New Models

Add to `internal/models/models.go`:

```go
type Person struct {
    PersonID        int       `json:"person_id" db:"person_id"`
    UserID          string    `json:"user_id" db:"user_id"`
    CharacterID     int       `json:"character_id" db:"character_id"`
    CharacterName   string    `json:"character_name" db:"character_name"`
    CharacterPortrait string  `json:"character_portrait"`  // computed as ESI URL
    CreatedAt       time.Time `json:"created_at" db:"created_at"`
    AssignmentCount int       `json:"assignment_count" db:"assignment_count"`
}

type CartItemDB struct {
    CartID  int64     `json:"cart_id" db:"cart_id"`
    UserID  string    `json:"user_id" db:"user_id"`
    ItemID  int       `json:"item_id" db:"item_id"`
    Region  string    `json:"region" db:"region"`
    AddedAt time.Time `json:"added_at" db:"added_at"`
}

type CartItemEnriched struct {
    ItemID               int           `json:"item_id"`
    ItemName             string        `json:"item_name"`
    Region               string        `json:"region"`
    CurrentMargin        float64       `json:"current_margin"`
    CurrentBuildCost     float64       `json:"current_build_cost"`
    CurrentSellPrice     float64       `json:"current_sell_price"`
    Trend                string        `json:"trend"`
    ProfitabilityHistory []ProfitPoint `json:"profitability_history"`
}

type ProfitPoint struct {
    Time      time.Time `json:"time"`
    Margin    float64   `json:"margin"`
    BuildCost float64   `json:"build_cost"`
    SellPrice float64   `json:"sell_price"`
}

type BatchAssignmentRequest struct {
    Assignments []BatchAssignmentItem `json:"assignments"`
}

type BatchAssignmentItem struct {
    ItemID   int `json:"item_id"`
    Region   string `json:"region"`
    PersonID int    `json:"person_id"`
}
```

---

## 8. Go API: New Endpoints Summary

| Method | Path | Handler | Auth |
|--------|------|---------|------|
| `POST` | `/people` | Create person | Yes |
| `GET` | `/people` | List people | Yes |
| `DELETE` | `/people/{person_id}` | Delete person (cascades) | Yes |
| `POST` | `/cart` | Add to cart | Yes |
| `POST` | `/cart/batch` | Batch add to cart | Yes |
| `GET` | `/cart` | Get cart (enriched) | Yes |
| `GET` | `/cart/count` | Get cart count | Yes |
| `DELETE` | `/cart/{item_id}/{region}` | Remove from cart | Yes |
| `DELETE` | `/cart` | Clear cart | Yes |
| `POST` | `/assignments/batch` | Batch assign + clear cart | Yes |
| `GET` | `/people/{person_id}/assignments` | Person's assignments | Yes |
| `DELETE` | `/assignments/{assignment_id}` | Delete single assignment | Yes |

**Auth enforcement:** All endpoints verify `X-Member-ID` header is present and non-empty. The Koa proxy populates this from the validated JWT. The Go API trusts it (internal network only).

---

## 9. Database Migration

Migration file: next sequential number in `internal/migration/`.

```sql
-- Create people table (keyed by EVE character ID)
CREATE TABLE IF NOT EXISTS people (
  person_id SERIAL PRIMARY KEY,
  user_id TEXT NOT NULL,
  character_id INT NOT NULL,
  character_name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, character_id)
);
CREATE INDEX IF NOT EXISTS idx_people_user ON people (user_id);
CREATE INDEX IF NOT EXISTS idx_people_character ON people (character_id);

-- Create cart table
CREATE TABLE IF NOT EXISTS cart (
  cart_id BIGSERIAL PRIMARY KEY,
  user_id TEXT NOT NULL,
  item_id INT NOT NULL,
  region TEXT NOT NULL,
  added_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, item_id, region)
);
CREATE INDEX IF NOT EXISTS idx_cart_user ON cart (user_id);

-- Add person_id to assignments (nullable during transition)
ALTER TABLE assignments ADD COLUMN IF NOT EXISTS person_id INT REFERENCES people(person_id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_assignments_person ON assignments (person_id);

-- Unique constraint for person-based assignments (partial, only non-null)
CREATE UNIQUE INDEX IF NOT EXISTS uniq_person_assignment
  ON assignments (person_id, item_id, region)
  WHERE person_id IS NOT NULL;
```

---

## 10. Koa Proxy Additions

Add to `sso-backend/src/routes/proxy.ts`:

```typescript
// Character search (for adding people via ESI public search)
router.post('/api/character-search', authRequired, async (ctx) => {
  const { search } = ctx.request.body;
  if (!search || search.length < 2) {
    return ctx.throw(400, 'Search query must be at least 2 characters');
  }
  
  try {
    // Call ESI public search using authenticated user's token
    const searchRes = await fetch(
      `https://esi.evetech.net/latest/characters/search?search=${encodeURIComponent(search)}&categories=character`,
      { headers: { 'Authorization': `Bearer ${ctx.state.accessToken}` } }
    );
    const characterIds = await searchRes.json();
    
    // Fetch character details for each ID
    const results = await Promise.all(
      characterIds.map(async (id) => {
        const charRes = await fetch(`https://esi.evetech.net/latest/characters/${id}`);
        const charData = await charRes.json();
        return {
          character_id: id,
          name: charData.name,
          portrait: `https://images.evetech.net/characters/${id}/portrait?size=32`
        };
      })
    );
    ctx.body = results;
  } catch (err) {
    ctx.throw(503, 'Character search unavailable');
  }
});

// People
router.post('/api/people', authRequired, proxy('/people'));
router.get('/api/people', authRequired, proxy('/people'));
router.delete('/api/people/:person_id', authRequired, proxy('/people/:person_id'));

// Cart
router.post('/api/cart', authRequired, proxy('/cart'));
router.post('/api/cart/batch', authRequired, proxy('/cart/batch'));
router.get('/api/cart', authRequired, proxy('/cart'));
router.get('/api/cart/count', authRequired, proxy('/cart/count'));
router.delete('/api/cart/:item_id/:region', authRequired, proxy('/cart/:item_id/:region'));
router.delete('/api/cart', authRequired, proxy('/cart'));

// Batch assignments
router.post('/api/assignments/batch', authRequired, proxy('/assignments/batch'));
router.get('/api/people/:person_id/assignments', authRequired, proxy('/people/:person_id/assignments'));
router.delete('/api/assignments/:assignment_id', authRequired, proxy('/assignments/:assignment_id'));
```

**Auth notes:**
- All `authRequired` routes inject `X-Member-ID` header from the validated JWT's character ID.
- Character search uses the logged-in user's EVE refresh token to query ESI (inherited from `ctx.state.accessToken` after OAuth2 flow).

---

## 11. ESI Character Search Integration

### Frontend: Character Search Autocomplete

The "Add Person" input field uses the ESI public search API to find characters by name.

**Flow:**
1. User types a character name (min 2 chars).
2. Frontend debounces the input (300ms) and calls `POST /api/character-search` with `{ "search": "alice" }`.
3. Koa backend proxies to ESI (`GET /latest/characters/search?search=alice&categories=character` using authenticated user's token).
4. ESI returns matching character IDs.
5. For each ID, Koa fetches character name and portrait via ESI (`GET /latest/characters/{id}`).
6. Frontend displays autocomplete dropdown with character portraits + names.
7. User selects a character → `POST /api/people` with `{ "character_id": 12345 }`.

**Response format:**
```json
[
  { "character_id": 12345, "name": "Alice Starweaver", "portrait": "https://images.evetech.net/characters/12345/portrait?size=32" },
  { "character_id": 99999, "name": "Alice's Alt", "portrait": "https://images.evetech.net/characters/99999/portrait?size=32" }
]
```

**Error Handling:**
- Empty search → `400 Bad Request`
- ESI unavailable → `503 Service Unavailable` with message "Character search unavailable"
- No results → `200` with empty array

### Go API: Character Validation

When `POST /people` is called with a `character_id`, the Go API validates:
1. Character exists via ESI `GET /latest/characters/{character_id}` (cache for 1h).
2. Character ID ≠ the logged-in user's own ID (`user_id` from `X-Member-ID` header).
3. Character not already in user's people list (unique constraint `(user_id, character_id)`).
4. If validation fails: `404 Not Found` (character doesn't exist), `400 Bad Request` (own character or duplicate).

---

## 12. Implementation Order

1. **Database migration** — Create `people`, `cart` tables; add `person_id` to `assignments`.
2. **Go models** — Add `Person`, `CartItemDB`, `CartItemEnriched`, `ProfitPoint`, `BatchAssignmentRequest` to `models.go`.
3. **Go DB methods** — Implement all CRUD in `internal/db/`.
4. **Go API routes** — Wire up all endpoints in `cmd/api-server/main.go`.
5. **Koa proxy routes** — Add proxy entries.
6. **Frontend types + hooks** — `person.ts`, `cart.ts`, update `assignment.ts`; `usePeople`, `useCart`, `useBatchAssign`.
7. **Opportunities page** — Checkboxes + floating action bar + cart add.
8. **Assignments page** — Refactor into tabs.
9. **Cart tab** — Item cards with `MarginSparkline`.
10. **People tab** — CRUD interface.
11. **AssignmentPanel** — Drag-drop full-screen dialog with dnd-kit.
12. **Polish** — Loading states, error handling, empty states, sidebar badge, already-in-cart indicators.

---

## 13. Edge Cases & Design Decisions

### Cart Staleness
Cart items may drop below 20% margin after being added. `GET /cart` returns **current** data. Items below threshold display a warning but remain actionable — the user decides.

### Cart Auto-Expiry
No automatic expiry. Cart is small (typically <20 items), and auto-expiry could surprise users who step away. Manual "Clear Cart" is always available.

### Assignment Conflicts
Two different users can assign the same `item_id + region` to their own people. Intentional — multiple corp members can build the same item. No exclusivity lock.

### Person Deletion Cascade
`ON DELETE CASCADE` removes all assignments for that person. UI shows confirmation with count.

### Cart Deduplication on Opportunities Page
Items already in the cart have disabled checkboxes. The cart set is fetched on page mount (lightweight `GET /cart/count` for badge; full cart check for dedup could use a separate `GET /cart/items` endpoint returning just `item_id+region` pairs if performance matters).

### Empty States
- **No people + non-empty cart:** "Assign Items" button disabled, tooltip: "Add people first".
- **Empty cart:** Cart tab shows "Browse Opportunities to add items" with link.
- **No assignments:** "Use the cart to plan and assign items."

---

## 14. Deprecation Plan

### Phase 1 (This Change)
- Old `POST /assign` and `DELETE /assign/:member_id/:item_id/:region` endpoints remain functional.
- Old "Assign to Me" button removed from frontend.
- `corp_member_id` column stays in `assignments` table (nullable).

### Phase 2 (Follow-up)
- Remove old assign/unassign endpoints from Go API.
- Drop `corp_member_id` column from `assignments`.
- Remove legacy assignment code from frontend.

---

## 15. TODO

- [ ] Write database migration
- [ ] Implement Go DB methods (people, cart, batch assign)
- [ ] Implement Go API handlers
- [ ] Add Koa proxy routes
- [ ] Add frontend types and hooks
- [ ] Refactor Opportunities page (checkboxes + cart add)
- [ ] Refactor Assignments page (tabs)
- [ ] Build Cart tab with MarginSparkline
- [ ] Build People tab
- [ ] Build AssignmentPanel with drag-drop
- [ ] Integration tests (batch assign, cart isolation, person cascade delete)
- [ ] E2E test (select → cart → assign → verify)
