API (FastAPI)
Base path: /api/v1. The Next.js app proxies /api/* to the backend service (same host cookies).
Auth
Web login uses rows in app_users on the control database linetra. Fachdaten live in linetra_<slug>. Postgres roles {slug}_edit / {slug}_read are the QGIS/API engines for that tenant (ADR-0031).
| Method | Path | Notes |
|---|---|---|
| POST | /auth/login |
Sets httpOnly session + CSRF cookie |
| POST | /auth/logout |
Clears cookies |
| GET | /auth/me |
Principal including tenant_id, can_create_project, and qgis_connection (host, port, dbname, role, sslmode, account_name / account_slug; password on Bearer including mTLS so the plugin can set the engine LOGIN; cookie sessions omit the password) |
| POST | /auth/token |
Editor-Bearer (Keychain); kein Cookie |
| PATCH | /auth/me |
Change own username (current password required) |
| POST | /auth/me/password |
Change own password (current password required) |
Mutating requests (except login/logout/token) require header X-CSRF-Token matching the CSRF cookie. Editor Bearer requests skip CSRF.
Error bodies are { "error": "<message>", "code": "<code>" }. Unexpected failures (including missing DB columns) return 500 server_error or 503 database_error with a generic message — they are not 401s. Failed login credentials return 401 invalid_credentials.
On empty app_users, startup seeds accounts from LINETRA_BOOTSTRAP_ADMIN_* (and optional LINETRA_BOOTSTRAP_EDITOR_*). Postgres roles LINETRA_APP_USER / LINETRA_READONLY_USER are DB engines only.
Users (org admin)
| Method | Path | Notes |
|---|---|---|
| GET | /users |
Staff of the caller's organization (platform operator with tenant_id null: all) |
| POST | /users |
Invite into the caller's organization (role admin/editor/reader, …) |
| PATCH | /users/{id} |
Role, is_active, optional password reset |
| DELETE | /users/{id} |
Permanent delete; not self; cannot remove last active org admin. Deactivate via PATCH { is_active: false }. |
| POST | /users/{id}/qgis-client-bundle |
Issue mTLS client cert ZIP; CN = Org-DB-Rolle; VERBINDUNG.txt names linetra_<slug> |
Organization accounts (tenants)
| Method | Path | Notes |
|---|---|---|
| GET | /tenants/me |
Bound organization |
| PATCH | /tenants/me |
Rename own organization (org admin or sysadmin) |
| GET | /tenants |
Own organization, or full catalog for system admin (can_manage_tenants) |
| POST | /tenants |
System admin only: provision linetra_<slug> + {slug}_edit/{slug}_read and first org admin in the body |
| PATCH | /tenants/{id} |
System admin: rename any organization |
| GET | /tenants/{id}/backup |
System admin: ZIP (pg_dump + control metadata + project files); header X-Deletion-Token (1h) unlocks delete |
| DELETE | /tenants/{id} |
System admin: hard-delete after { confirm_slug, deletion_token }. Bootstrap org and LINETRA_SHARED_DB refuse. |
POST /projects (create) requires can_create_project (org admin); the caller becomes owner. DELETE /projects/{id} requires owner (or org admin on an unclaimed legacy project). Editors keep org can_edit for Stamm; project updates and GIS need project role owner or editor. List and get are membership-scoped (ADR-0052).
Stammdaten
Paginated CRUD:
/organizations/persons(Filter über Mitgliedschaften; Responseorganization_ids)/projects(membership-scoped; flagsmy_role,can_edit_gis,can_edit_project,can_manage_members,can_delete_project. Org admins also see projects with no members yet)GET/POST /projects/{id}/members,PATCH/DELETE /projects/{id}/members/{membership_id},GET /projects/{id}/eligible-users/lookups/project-status/actors(paginated union:kind= person | company | municipality,q)
Schema-CMS (Management)
Flexible entity kinds with hybrid QGIS compat (ADR-0025, ADR-0033):
| Method | Path | Notes |
|---|---|---|
| GET/POST | /catalog-checkout |
Exclusive schema session. GET returns holder, is_dirty, is_holder, can_unlock, can_force. Parked dirty lock looks locked (is_holder=false) and keeps the overlay. GET /diff (holder): kinds/fields added/removed/changed with before/after. POST /unlock (resumes a parked draft), /apply ({ confirm: true }), /discard, /lock (parks dirty overlay), /force-lock (org admin). Schema writes 409 without an unparked holder (ADR-0046) |
| GET/POST | /schema-definitions |
Named schemas with projects[] (id + name). Without ?project_id= the list is standard first, then every custom pack; with it, only that project's packs plus standard. Create is always project-bound (is_federated=true rejected) (ADR-0037). Holder reads pass ?working=1 |
| GET/PUT/DELETE | /schema-definitions/{code} |
PUT renames a custom pack (label). Standard (standard) cannot be renamed or deleted |
| GET | /schema-definitions/{code}/er |
Compact ER graph: kinds as nodes (entity_type), relation/parent edges with cardinality and required. Holder overlay: ?working=1 |
| GET/POST | /entity-kinds |
List/create; optional ?entity_type=dataset\|group\|layer; ?schema_code=; holder overlay ?working=1. Writes 409 without checkout. instantiation_scope Global (base) always lands on standard; optional parent_code (ADR-0045): group→group (nest), layer→layer or dataset→dataset (one inherit step, live fields, parent abstract). Leaf layers list groups in group_codes (ADR-0048); inherit parents cannot. Layer requires geom_kind (point/linestring/polygon). Groups have no fields. If the parent has records or GIS features, parent_instances=transfer\|delete is required. is_federated only on standard datasets (ADR-0043) |
| POST | /entity-kinds/{code}/promote |
Create a layer child under a dataset (inherits fields, upserts geo_object_kinds when the layer is a leaf); source becomes abstract; same parent_instances choice |
| GET/POST | /selection-lists |
Named pick lists (code, label, entries[]) for enum/list fields (ADR-0042) |
| GET/PUT/DELETE | /selection-lists/{code} |
One list. Delete is 409 while a field still binds list_code |
| GET/POST | /entity-kinds/{code}/fields |
List (incl. inherited, optional ?project_id= / ?schema_code=) / create field. instantiation_scope Global (base) stays on standard; pack-local (project) needs schema_code of that named schema. Every field has cardinality (n or 1–99) and required (ADR-0039). Enum/list require list_code unless list + item_type: group. Relation requires config.target_kind (existing entity kind, not a group) |
| GET/PUT/DELETE | /entity-kinds/{code} |
Seeded kinds deletable (QGIS warning). DELETE ?retain_in_projects=true demotes a global kind so existing project schemas keep it (ADR-0038) |
| PUT/DELETE | /entity-kinds/{code}/fields/{field_id} |
Update/delete field. DELETE ?retain_in_projects=true keeps a global field on existing project schemas |
| GET/POST | /entities?kind= |
Paginated records (q, project_id, scope=central\|project). Federated data objects always use the shared pool (project_id NULL); project_id then requires a schema binding (403 otherwise) |
| GET/PUT/DELETE | /entities/{record_id} |
One record |
| POST/GET | /entity-files |
Upload / download image or document |
| GET | /catalog/{kind} |
Central records only (project_id null), paginated |
| POST | /projects/{id}/catalog/pull |
Copy selected central records (max 100) |
| POST | /projects/{id}/sync |
Catalog sync (direction, kinds); Celery or eager |
| GET | /projects/{id}/sync/conflicts |
Open conflict inbox |
| POST | /projects/{id}/sync/conflicts/{id}/resolve |
{ winner: central\|project } |
| POST | /projects/{id}/schema/sync-from-catalog |
Additive base bindings |
| POST | /projects |
Always creates a named project schema. clone_schema_from copies bindings from that project; omit for Standard (ADR-0047) |
| GET | /analyse/summary?project_id= |
Counts by layer kind (no geometries) |
| GET | /analyse/overview?project_id= |
Dashboard KPIs: geo_object counts by kind/status, schema number-field sums, procedure status + open drafts |
| GET | /analyse/field?project_id=&kind=&field= |
Bounded JSONB histogram |
| GET | /analyse/catalog?project_id=&kind= |
Central vs project-copy counts |
| GET | /qgis/layers?project_id= | Plugin apply: groups + leaf-layer views. can_edit is true for project owner/editor, false for reader. Membership required when project_id is set (ADR-0052). Omit project_id for the whole published catalog with can_edit=false (ADR-0049) |
Default seed kinds sync to projects / persons / organizations / geo_object_kinds. Leaf layers also project qgis_<kind> views.
GIS import (planner → agent)
| Method | Path | Notes |
|---|---|---|
| POST | /projects/{id}/import/upload |
Multipart .gpkg / GeoJSON / Shapefile .zip |
| POST | /projects/{id}/import/inspect |
{ file_id } — GDAL layer/field/CRS summary |
| GET | /projects/{id}/import/files/{file_id} |
Upload + cached inspect |
| GET/POST/PUT/DELETE | /projects/{id}/import/profiles |
Per-project mapping profiles |
| POST | /projects/{id}/import/run |
Mapped import → geo_objects (Celery); no pipeline stage |
Compat: POST /map/import still accepts inline GeoJSON for simple imports (ADR-0026).
Mutations require admin or editor. projects.project_manager stores persons.person_id as text (QGIS Value Relation). organizations.contact_person_id on write sets primary membership, not a separate FK column.
Reports
| Method | Path | Notes |
|---|---|---|
| GET | /reports/summary |
Chart aggregates |
| GET | /reports/export.pdf |
PDF download |
Map
Hintergrund: MVT. Identify: GeoJSON eines Objekts. Geometrie-POST/PATCH nur Editor (Bearer). Dashboard ändert Fachfelder über /map/objects/{id}/attributes.
| Method | Path | Notes |
|---|---|---|
| GET | /map/tiles/{stage}/{z}/{x}/{y}.mvt?project_id=&kind= |
Auth: session cookie or editor Bearer. {stage} is ignored (kept for old clients). Filter in 25832, clip 3857 |
| GET | /map/layer-tree?project_id= |
Schema explorer: groups + leaf layers with counts, plus extent_3857 (padded Web-Mercator box, or null if empty). No geometries in the tree. No pipeline stages / draft / versions (ADR-0045) |
| GET | /map/layer-colors |
Hex catalog { stages: {}, kinds } — kind swatches only |
| PUT | /map/layer-colors/{kind}/{code} |
Set one kind swatch (#RRGGBB); editor/admin. Scope stage is rejected |
| GET | /map/objects?project_id=&kind=§ion_id=&layer_id= |
Paginated feature list (no geometry). stage / version_id unused leftovers |
| GET | /map/objects.geojson?project_id=&kinds= |
Layer export: FeatureCollection EPSG:25832, max 10 000; optional comma-separated kinds |
| GET/POST/PUT/DELETE | /map/kinds |
GIS product-kind registry (geo_object_kinds) |
| GET | /map/objects/{id} |
GeoJSON + attrs + Beilage |
| PATCH | /map/objects/{id}/attributes |
Dashboard: label + Beilage only (admin/editor cookie) |
| POST | /map/project-versions |
410 — pipeline removed (ADR-0045) |
| PATCH | /map/project-versions/{version_id} |
410 |
| POST | /map/objects/copy-to-normierung |
410 |
| POST/PATCH | /map/objects |
Editor only (geometry required). stage / version_id ignored (always null) |
| DELETE | /map/objects/{id} |
Editor only; detaches leftover version pins first |
Collaboration / reports / export
| Method | Path | Notes |
|---|---|---|
| GET/POST | /projects/{id}/chat |
Kunde ↔ Betreiber |
| GET/POST | /projects/{id}/tickets |
Optional jira_key, kein Jira-Sync |
| GET/POST | /product-reports/sets |
Feld-Sets (Admin schreibt) |
| POST | /exports/ltp/{project_id} |
Celery .ltp (Magic LINETRA-PP\\0) |
| POST | /exports/ltp/{project_id}/import |
Roundtrip-Import der aktuellen Objekte |
| GET | /exports/ltp/{project_id} |
Download des letzten .ltp |
Procedures (Verfahren)
Pagination auf Listen. Behörden-Filter räumlich (ST_Intersects). GIS-Write nicht in diesem Namespace.
| Method | Path | Notes |
|---|---|---|
| GET | /kind-packs |
Pack-Registry inkl. Mitglieds-Kinds |
| GET/PUT | /projects/{id}/kind-packs |
Aktivierte Vertikale des Projekts |
| GET/POST | /pipeline/sections |
work_sections; POST darf geom (GeoJSON 25832) oder Linearref |
| PATCH/DELETE | /pipeline/sections/{section_id} |
Umbenennen; DELETE mit ?delete_contents=true löscht Features, sonst section_id auf NULL |
| GET | /procedures |
Filter: project_id, status, q; Pagination |
| POST | /procedures |
Antrag auf Abschnitt (draft) |
| GET | /procedures/{id} |
Antrag + Auflagen + letzte Events |
| POST | /procedures/{id}/transitions |
Body { to_status }; 409 wenn illegal |
| GET/POST | /procedures/{id}/conditions |
Auflagen |
| GET | /procedures/{id}/events |
Audit (letzte N, default 50) |
| GET | /procedures/{id}/events.csv |
Audit-Export; ?project_id= auf Collection-Export |
| GET | /notifications |
In-App-Inbox |
| GET | /field/sections |
nur approved / approved_with_conditions im Gebiet (foreman/admin) |
| POST | /field/photos |
Foto in File-Store, Query procedure_id |
| POST | /field/reports |
Fortschritt + optionale file_id |
Logs
In-process ring buffer of structured JSON events from this API process (not Celery or Next.js). Capacity and default level come from LOG_BUFFER_SIZE / LOG_LEVEL (or DEBUG=true). Org admins and system admins only.
| Method | Path | Notes |
|---|---|---|
| GET | /logs |
Paginated; page, page_size, min_level, levels (comma-separated), q, sort (timestamp_desc default, timestamp_asc, level_desc, level_asc) |
| GET | /logs/config |
Live process level, env default, buffer_size, captured |
| PATCH | /logs/config |
{ log_level } runtime overlay — resets on process restart |
Secret-like extra keys (password, token, …) are redacted as [redacted].
Health
| Method | Path | Notes |
|---|---|---|
| GET | /health |
Liveness |
| GET | /health/dependencies |
Postgres, Redis, Celery, Docs |
OpenAPI: /docs and /openapi.json on the API process (inside Compose, typically via backend:8000). Hosted product docs: Next.js /documentation/ → Compose docs.