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
Migrationdrizzle/0095_tender_terms_and_project_scope.sql adds:
projects.scope: nullablejsonb, 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.modeis'text','pdf', orNULL(no terms).text_documentis the typed document and is kept when the mode changes to'pdf'. The checkmode IS DISTINCT FROM 'text' OR text_document IS NOT NULLprevents a'text'mode with no document; the route refuses that first with422 terms_text_missing.organisation_terms_pdfs: one row per accepted PDF. The bytes never live here, only the object key (terms/<orgId>/<id>.pdfin the private branding bucket), the cleaned file name, byte size, and SHA-256 hex.removed_atis a soft delete. A partial unique index on(organisation_id) WHERE removed_at IS NULLkeeps one active PDF per company; a concurrent confirm answers409 terms_pdf_changed_concurrently.
Rich-text documents
The typed terms and the project scope are both checked bysrc/lib/richText.ts before they are stored. The stored document is rebuilt from what was checked.
- Nodes:
doc,paragraph,heading(level2 or 3),bulletList,orderedList,listItem,hardBreak,text. - Marks:
bold,italic,underline. - Limits: 50,000 text characters or 200 KB of JSON.
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):- Presign:
POST /organisation-terms/pdf-uploadswith{ contentType, byteSize }. No database row is written. The policy pins the key, content type, and acontent-length-rangeof 1 to 10,485,760 bytes. - Upload: the browser posts the file to
pending/<orgId>/<uploadId>. - Confirm:
POST /organisation-terms/pdf-uploads/:uploadId/confirmwith{ fileName }. The service rebuilds the pending key from the caller’s organisation, reads the bytes back, validates them, copies the object toterms/<orgId>/<id>.pdf, then soft-removes the previous active row, inserts the new row, and setsmodeto'pdf'in one transaction.
'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/:idreturnsscope(a document ornull).POST /api/global/projectsandPUT /api/global/projects/:idacceptscope: omitted keeps the stored value,nullclears it, and a valid document replaces it.
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 readsGET /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.
