> ## 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.

# Organisation terms and project scope

> How company terms and conditions and the project scope are stored, validated and read by the tender download

Company terms and conditions let an organisation add its standard terms to the tender download. An admin either types them as rich text or uploads a PDF, and `mode` says which form the tender download uses. The project scope is a separate rich-text field on each project, printed first in the tender download.

The data and routes live in `stralos-api-global` (`src/routes/organisation-terms.ts`, `src/routes/projects.ts`). The **Terms and conditions** card in **Settings → Company defaults** is `CompanyTermsPanel` in `stralos-feature-organisation`. The **Project scope** section is `ProjectScopeCard` in `stralos-feature-project-settings`. `stralos-api-estimate` reads both when it builds a tender download (see [Estimate API](https://help.stralos.app/api/estimate.md#tender-submission-pdf)).

## Data model

Migration `drizzle/0095_tender_terms_and_project_scope.sql` adds:

* `projects.scope`: nullable `jsonb`, the project's scope as a rich-text document. Only the single-project read carries it; the project list never does.
* `organisation_terms`: one row per company. `mode` is `'text'`, `'pdf'`, or `NULL` (no terms). `text_document` is the typed document and is kept when the mode changes to `'pdf'`. The check `mode IS DISTINCT FROM 'text' OR text_document IS NOT NULL` prevents a `'text'` mode with no document; the route refuses that first with `422 terms_text_missing`.
* `organisation_terms_pdfs`: one row per accepted PDF. The bytes never live here, only the object key (`terms/<orgId>/<id>.pdf` in the private branding bucket), the cleaned file name, byte size, and SHA-256 hex. `removed_at` is a soft delete. A partial unique index on `(organisation_id) WHERE removed_at IS NULL` keeps one active PDF per company; a concurrent confirm answers `409 terms_pdf_changed_concurrently`.

Both tables use forced row-level security with the standard tenant isolation policy. The request role can select, insert, and update but not delete; only the platform role deletes rows, during an organisation purge.

### Rich-text documents

The typed terms and the project scope are both checked by `src/lib/richText.ts` before they are stored. The stored document is rebuilt from what was checked.

* **Nodes:** `doc`, `paragraph`, `heading` (`level` 2 or 3), `bulletList`, `orderedList`, `listItem`, `hardBreak`, `text`.
* **Marks:** `bold`, `italic`, `underline`.
* **Limits:** 50,000 text characters or 200 KB of JSON.

A document with no visible text, including one of only spaces, is stored as `NULL`. Anything outside the vocabulary is a plain `422`; a document is never partly stored.

## Terms routes

All routes sit under `/api/global`, and every one acts on the organisation in the caller's token. A contractor working in a client company sees and edits that company's terms, never their own.

| Method and path | Access | Purpose |
| - | - | - |
| `GET /organisation-terms` | Any member | `{ mode, text, pdf }`. Both forms are returned whatever the mode; `pdf` carries a five-minute presigned `url` and the file's `sha256`. `Cache-Control: private, no-store`. |
| `PUT /organisation-terms` | Admin | `{ mode, text? }`. Omitted `text` keeps the stored document; `null` clears it. |
| `POST /organisation-terms/pdf-uploads` | Admin | Presigned POST for a PDF upload straight to S3. |
| `POST /organisation-terms/pdf-uploads/:uploadId/confirm` | Admin | Validate the upload, move it to permanent storage, and make `'pdf'` the mode. |
| `DELETE /organisation-terms/pdf` | Admin | Soft-remove the active PDF; a `'pdf'` mode becomes `null`. |

`PUT` refusals: `terms_text_invalid`, `terms_text_too_long`, `terms_text_missing` (mode `'text'` with no document), and `terms_pdf_missing` (mode `'pdf'` with no stored PDF), all `422`.

### Upload flow

The PDF upload uses the same pipeline as the company logo (see [Company branding](https://help.stralos.app/internal/company-branding.md)):

1. **Presign:** `POST /organisation-terms/pdf-uploads` with `{ contentType, byteSize }`. No database row is written. The policy pins the key, content type, and a `content-length-range` of 1 to 10,485,760 bytes.
2. **Upload:** the browser posts the file to `pending/<orgId>/<uploadId>`.
3. **Confirm:** `POST /organisation-terms/pdf-uploads/:uploadId/confirm` with `{ fileName }`. The service rebuilds the pending key from the caller's organisation, reads the bytes back, validates them, copies the object to `terms/<orgId>/<id>.pdf`, then soft-removes the previous active row, inserts the new row, and sets `mode` to `'pdf'` in one transaction.

If the upload has the same SHA-256 as the active PDF, confirm keeps the existing record, makes no copy, and sets the mode to `'pdf'`. A pending object that is never confirmed expires by the bucket's lifecycle rule within a day.

### PDF validation

`src/lib/termsPdf.ts` decides from the bytes, never from the file name or declared type alone:

| Code | Condition |
| - | - |
| `terms_pdf_unsupported_type` | Declared type is not `application/pdf`, or no `%PDF-` header in the first 1,024 bytes. |
| `terms_pdf_too_large` | Larger than 10 MB. |
| `terms_pdf_unreadable` | Empty body. |
| `terms_pdf_locked` | The trailer carries an `/Encrypt` key. api-estimate appends the pages with `pdf-lib`, which cannot copy pages out of an encrypted file, so the admin is told at upload rather than an estimator at download. |
| `terms_pdf_upload_missing` | The pending object was not found (expired or never uploaded). |

### Storage

The terms PDFs and pending uploads live in the private branding bucket (`terraform/branding-bucket.tf`), under their own `terms/` prefix. When `BRANDING_BUCKET` is unset (a local stack without the bucket), `GET /organisation-terms` returns `pdf.url: null`, and the upload routes answer `503 terms_storage_unavailable`. The organisation purge deletes both tables' rows and the stored objects, and refuses to purge an organisation with a terms PDF when the bucket is not configured.

## Project scope

* `GET /api/global/projects/:id` returns `scope` (a document or `null`).
* `POST /api/global/projects` and `PUT /api/global/projects/:id` accept `scope`: omitted keeps the stored value, `null` clears it, and a valid document replaces it.

The project PUT carries the whole project, so `ProjectScopeCard` sends the saved client, name, coordinates, and country with the new scope, and refuses to save until those exist. Refusals are `422 project_scope_invalid` and `422 project_scope_too_long`.

## How the tender download reads them

api-estimate reads `GET /organisation-terms` with the caller's forwarded bearer, so a tender always uses the terms of the company that owns the estimate. It switches on `mode`, downloads a PDF from the presigned URL without the bearer, and checks the bytes against `sha256`. The scope rides on the project read the export already makes. Neither read is fail-soft: a failure stops the download with a `502` (`tender_terms_unavailable`, `tender_project_scope_unavailable`) rather than a tender with a section missing.

The tender download can also draft a scope with AI through api-documents (`POST /projects/:projectId/tender-scope-draft`, see [Documents API](https://help.stralos.app/api/documents.md#tender-scope-draft-endpoint)). The draft is never saved until the estimator reviews it and saves it through the project PUT.


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