> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Tender approval and private links

> How api-estimate stores tender approval requests and private tender links, their routes, and the public link page

Tender approval and private tender links both live in `stralos-api-estimate` (migration `0114_tender_approvals_links.sql`, schema in `src/db/tenderApprovalSchema.ts`). The company setting that shows the approval UI, `tendersNeedApproval`, is in [api-global's organisation settings](https://help.stralos.app/api/global.md#get-organisation-settings); api-estimate never reads it. For the estimator's view, see [Tender approval](https://help.stralos.app/guides/estimators/tender-submission.md#tender-approval) and [Share a link](https://help.stralos.app/guides/estimators/tender-submission.md#share-a-link).

## Tender approval

Approval is a record only. The tender stays a draft, nothing here changes its status, and Submit never reads it.

* A request belongs to a revision: a draft's is the one it will become. A revised tender starts with no approval.
* One waiting request per estimate, enforced by the partial unique index `estimate_tender_approvals_one_waiting`. Sending a new request withdraws the waiting one in the same transaction.
* A decision or withdrawal is written once, by an `UPDATE` guarded on `status = 'waiting'`. `CHECK` constraints tie the decision and withdrawal columns to the status.
* The approver is a business person, never a contractor. Before the row is written, api-estimate asks api-global to put them on the project (`POST /api/global/service/organisations/:orgId/projects/:projectId/approvers`), forwarding the estimator's own token, so api-global decides who may be added.

### Routes

All under `/api/estimate` (`src/routes/tenderApprovalRoutes.ts`):

| Method and path | Purpose |
| - | - |
| `GET /estimates/:id/approvals` | Every request, newest first, and `current` (the newest on the current revision, or null). |
| `POST /estimates/:id/approvals` | Send for approval: `{ approverUserId, note? }`. `400 approver_is_you`; `403 project_access_required`; `404 approver_not_found`; `409 approval_not_for_this_estimate` for a non-tender estimate; `409 estimate_frozen` once the tender is not editable; `502 approver_add_failed` when the project add fails. |
| `POST /estimates/:id/approvals/:approvalId/decision` | `{ outcome: "approved" \| "sent_back", note? }`. Only the approver (`403 approval_not_yours`). Sending back needs a note (`400 approval_note_required`). `409 approval_not_waiting`. |
| `POST /estimates/:id/approvals/:approvalId/withdraw` | The person who asked, or an admin (`403 approval_withdraw_not_allowed`). `409 approval_not_waiting`. |

Notes are trimmed and at most 1,000 characters.

Each approval carries its status (`waiting`, `approved`, `sent_back`, `withdrawn`), the names of the requester, approver and withdrawer, the notes, and three prices excluding GST: `priceAtRequest`, `priceAtDecision`, and `currentPrice` (a submitted revision's frozen price, otherwise the live total). `figuresChangedSince` compares the current price to the cent with the price at request (waiting) or at decision (approved), and is false otherwise. `canDecide` is true for the approver of a waiting request, and `canWithdraw` for its requester or an admin.

Requests, decisions, and withdrawals enqueue `tender_approval_requested`, `tender_approval_decided`, and `tender_approval_withdrawn` in the row's transaction; they are sent after it commits. See [Notifications](https://help.stralos.app/internal/notifications.md).

## Private tender links

A private link shows one recipient the tender as a web page with no sign-in. It is api-estimate's first unauthenticated surface over tenant data, so the public half is deliberately narrow (`src/routes/tenderLinkRoutes.ts`):

* The token is 32 random bytes and only its SHA-256 is stored (`estimate_tender_links_token_hash_unique`). The URL is in the create response only.
* A malformed or unknown token is a `404` before any tenant read. A known hash is looked up on the platform pool in one narrow query; everything after runs on the RLS runtime pool confined to the link's organisation and project (`withProjectTenant`).
* The public routes write only the view log, the first-open stamp, and its notification: no save, no D\&C sync.
* The auth exemption in `app.ts` names exactly the three public `GET` routes, never a prefix.
* Responses carry `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow, noarchive`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and a CSP with no script. No estimate id appears on the page.

### What the page shows

A link belongs to the revision it was made on (a draft's is the one it will become). The page is drawn on every request from that revision's figures now: the draft while it is being priced, the estimate while it is frozen at that revision, then that revision's saved version once the tender is revised. Once a newer revision is submitted, the page says the tender has been updated and gives the email of whoever submitted it.

A link works until the tender's validity ends: the end of the local day 30 days after the tender's date. With prices left out, the page shows the schedule with quantities only, and the file routes answer `404`.

### Routes

Member routes, scoped like every estimate route:

| Method and path | Purpose |
| - | - |
| `POST /estimates/:id/tender-links` | Make a link: a `label` (up to 120 characters) and `leavePricesOut`. `409 tender_link_not_allowed` unless the estimate is a tender in progress, or submitted with a committed revision. |
| `GET /estimates/:id/tender-links` | The estimate's links with their state and view count. |
| `POST /estimates/:id/tender-links/:linkId/turn-off` | Turn a link off. It is kept, with its views. `404 tender_link_not_found`. |
| `GET /estimates/:id/tender-links/:linkId/views` | Every view, newest first, at most 500 (`truncated` when more). |

Public routes:

| Method and path | Purpose |
| - | - |
| `GET /tender-links/:token` | The tender page, or a state page for a link that is turned off, expired, updated, or unavailable. |
| `GET /tender-links/:token/pdf` | The revision's stored as-submitted PDF. |
| `GET /tender-links/:token/xlsx` | The revision's stored as-submitted workbook. |

### States and limits

| State | Condition |
| - | - |
| `active` | Works and shows the tender. |
| `turned_off` | A member turned it off. |
| `expired` | Past its works-until time. |
| `updated` | A newer revision has been submitted. |
| `unavailable` | The estimate can no longer be shown through the link. |

Per link, 60 page views and 30 file opens an hour, counted from the view log (`429` with `Retry-After`). Unknown tokens are limited to 30 misses per network per 10 minutes on each instance. Each view records when, what was opened (`page`, `pdf`, or `xlsx`), the outcome, a rough browser name, and the network as an IPv4 `/24` or IPv6 `/48`, never a full address.

The first view that shows the tender stamps the link and enqueues `tender_link_first_opened` for the person who made it. The link row also keeps a snapshot of the names and branding it was made with (`display`, `logo`).

Approvals and links are not copied when an estimate is copied.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.