Skip to main content
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).

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. 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):
  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:

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). The draft is never saved until the estimator reviews it and saves it through the project PUT.