# BP Panel — List sorting (frontend)

Guide for **Business Partner (BP)** list screens under `/api/v1`. Use these query parameters to control item order on paginated GET endpoints.

This does **not** apply to Kanban board card order (see [board-api.md](./board-api.md) — cards use `order` on move/reorder APIs).

---

## Quick reference

| Parameter | Type | Default | Allowed values |
|-----------|------|---------|----------------|
| `sort_by` | string | `created_at` | MongoDB field name (see per-endpoint tables) |
| `sort_order` | string | `desc` | `asc` or `desc` |

**Example (newest tenders first — also the default if omitted):**

```http
GET /api/v1/tenders?limit=20&sort_by=created_at&sort_order=desc
Authorization: Bearer <customer_access_token>
```

**Example (soonest deadline first):**

```http
GET /api/v1/tenders?limit=20&sort_by=tender_deadline&sort_order=asc
```

---

## Authentication

All BP list endpoints below require a **customer** JWT from:

```http
POST /api/v1/profile/login
```

```http
Authorization: Bearer <access_token>
```

Base URL (local): `http://localhost:8081/api/v1`

---

## How sorting works

1. **`sort_by`** must match the **BSON field name** stored in MongoDB (snake_case), not the JSON display name in the UI.
2. **`sort_order`**
   - `desc` — highest / latest values first (default).
   - `asc` — lowest / earliest values first.
3. If you omit both parameters, the API uses **`sort_by=created_at`** and **`sort_order=desc`**.
4. Lists use **keyset (cursor) pagination**. When requesting page 2+ with `cursor`, you **must keep the same** `sort_by`, `sort_order`, `limit`, and filters as the request that produced `meta.next_cursor`. Changing sort mid-pagination returns **400** (`cursor does not match requested sort`).
5. Do not send **`cursor` and `offset` together** — the API returns **400**.

### Pagination parameters (used with sorting)

| Parameter | Default | Max | Notes |
|-----------|---------|-----|--------|
| `limit` | `10` | `100` | Page size |
| `offset` | `0` | — | First page only; prefer `cursor` for deep pages |
| `cursor` | — | — | Copy from `meta.next_cursor` on the previous response |

### Response metadata

Sorted list responses include `meta` on the envelope:

```json
{
  "success": true,
  "message": "Tenders retrieved successfully",
  "data": { "tenders": [] },
  "meta": {
    "total": 240,
    "limit": 20,
    "offset": 0,
    "page": 1,
    "pages": 12,
    "has_more": true,
    "next_cursor": "eyJ2IjoxLCJzb3J0X2ZpZWxkIjoiY3JlYXRlZF9hdCIs..."
  }
}
```

---

## Endpoints that support `sort_by` / `sort_order`

### Tenders — main panel list

| | |
|--|--|
| **Method / path** | `GET /api/v1/tenders` |
| **Also** | `GET /api/v1/tenders/recommend` (same sorting rules) |
| **Default sort** | `created_at` `desc` |

**Recommended `sort_by` values** (field exists on tender documents):

| `sort_by` | Use case |
|-----------|----------|
| `created_at` | Newest ingested / default |
| `updated_at` | Recently changed tenders |
| `publication_date` | Publication date on TED notice |
| `tender_deadline` | Submission / tender deadline |
| `submission_deadline` | Alternative deadline field when set |
| `estimated_value` | Value high → low or low → high |
| `title` | Alphabetical (locale-dependent) |
| `status` | Group by lifecycle status |
| `country_code` | Group by country |

> The backend does not whitelist tender `sort_by` values. Use only documented fields; unknown fields may sort incorrectly or behave unpredictably.

**Example with filters + sort:**

```http
GET /api/v1/tenders?limit=20&country_codes=DE,FR&sort_by=publication_date&sort_order=desc&deadline_from=1717200000
```

**TypeScript helper:**

```typescript
type SortOrder = "asc" | "desc";

interface ListSortParams {
  sort_by?: string;
  sort_order?: SortOrder;
  limit?: number;
  offset?: number;
  cursor?: string;
}

function buildTenderListQuery(
  filters: Record<string, string | number | string[] | undefined>,
  sort: ListSortParams = { sort_by: "created_at", sort_order: "desc", limit: 20 }
): string {
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(filters)) {
    if (value === undefined) continue;
    if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
    else params.set(key, String(value));
  }
  params.set("sort_by", sort.sort_by ?? "created_at");
  params.set("sort_order", sort.sort_order ?? "desc");
  if (sort.limit) params.set("limit", String(sort.limit));
  if (sort.cursor) params.set("cursor", sort.cursor);
  else if (sort.offset) params.set("offset", String(sort.offset));
  return params.toString();
}
```

---

### Notifications

| | |
|--|--|
| **Method / path** | `GET /api/v1/notifications` |
| **Default sort** | `created_at` `desc` |

**Recommended `sort_by` values:**

| `sort_by` | Use case |
|-----------|----------|
| `created_at` | Newest notifications (default) |
| `updated_at` | Last updated |
| `seen_at` | Seen / unread ordering |
| `priority` | Important first (with `sort_order=desc`) |
| `schedule_at` | Scheduled notifications |

---

### Feedback (company list)

| | |
|--|--|
| **Method / path** | `GET /api/v1/feedback` |
| **Default sort** | `created_at` `desc` |
| **Scope** | Company-scoped from the logged-in customer |

**Recommended `sort_by` values:**

| `sort_by` | Use case |
|-----------|----------|
| `created_at` | Newest feedback (default) |
| `updated_at` | Recently changed |
| `feedback_type` | Group likes / dislikes |

---

## Endpoints without `sort_by` / `sort_order` (BP)

| Endpoint | Notes |
|----------|--------|
| `GET /api/v1/tender-approvals` | Uses `limit` / `offset` only; **no** `sort_by` on the public handler today |
| `GET /api/v1/companies` | Single “my company” resource, not a sortable list |
| Kanban (`/admin/v1/kanban/...`) | Admin-only; card order is `order` + move/reorder APIs |

If you need sorted tender approvals in BP, coordinate with backend to add `sort_by` / `sort_order` to `PublicGetTenderApprovals` (admin list already supports them).

---

## UI patterns

### Column header → query params

| UI label | `sort_by` | Typical `sort_order` |
|----------|-----------|----------------------|
| Date added | `created_at` | `desc` |
| Deadline | `tender_deadline` | `asc` |
| Value | `estimated_value` | `desc` |
| Published | `publication_date` | `desc` |

Toggle the same column: flip `asc` ↔ `desc`. Changing column: set new `sort_by` and reset to page 1 (`offset=0`, drop `cursor`).

### Infinite scroll / “Load more”

1. First request: `sort_by`, `sort_order`, `limit`, filters — no `cursor`.
2. Next page: repeat the **same** sort and filters; pass `cursor=meta.next_cursor`.
3. Stop when `meta.has_more === false`.

---

## Errors

| Situation | HTTP | `error.code` (typical) |
|-----------|------|-------------------------|
| Invalid `sort_order` (not `asc`/`desc`) | 400 | `BAD_REQUEST` |
| `cursor` + `offset` together | 400 | `BAD_REQUEST` |
| `cursor` with different `sort_by` / `sort_order` | 400 | `BAD_REQUEST` |
| `limit` &lt; 1 or &gt; 100 | 400 | `BAD_REQUEST` |

---

## Admin panel (reference)

Admin list routes under `/admin/v1/*` use the same `sort_by` / `sort_order` conventions where `response.NewPagination` is used. Some admin forms restrict values (e.g. tender approvals: `created_at`, `updated_at`, `status`, `submission_mode`). BP tender lists are **less restricted** than admin tender-approval lists.

---

**Source:** `pkg/response/pagination.go`, `pkg/mongo/list_pagination.go`, `internal/tender`, `internal/notification`, `internal/feedback` · **Last updated:** 2026-05-31
