feat(frontend): add editor integration shell and draft read-model views
- add editor integration shell - add editor context read flow - add draft flow entry handling - add summary, structure, validation and compare-preview views - render backend read models for draft and published states - verify sample-contract entities: 3 seats, 1 group, 1 sector
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
VITE_FRONTEND_PORT=28080
|
VITE_FRONTEND_PORT=28080
|
||||||
VITE_APP_TITLE=SVG Service Admin UI
|
VITE_APP_TITLE=SVG Service Frontend
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:9020
|
VITE_API_BASE_URL=http://127.0.0.1:9020
|
||||||
VITE_API_KEY=admin-local-dev-key
|
VITE_API_KEY=admin-local-dev-key
|
||||||
|
|||||||
260
doc/README.md
Normal file
260
doc/README.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# svg-service backend
|
||||||
|
|
||||||
|
Backend for SVG scheme upload, draft editing, pricing, diagnostics, publish preview, and publish lifecycle.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Python 3.11
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy async
|
||||||
|
- PostgreSQL 16
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
Default backend port: `9020`
|
||||||
|
|
||||||
|
Health check:
|
||||||
|
- `GET /healthz`
|
||||||
|
|
||||||
|
Main API prefix:
|
||||||
|
- `/api/v1`
|
||||||
|
|
||||||
|
Auth header:
|
||||||
|
- `X-API-Key`
|
||||||
|
|
||||||
|
Default local admin key:
|
||||||
|
- `admin-local-dev-key`
|
||||||
|
|
||||||
|
## Core lifecycle
|
||||||
|
|
||||||
|
1. Upload SVG
|
||||||
|
2. Normalize and persist structure
|
||||||
|
3. Enter editor flow through context + ensure draft
|
||||||
|
4. Edit sectors / groups / seats in current draft
|
||||||
|
5. Configure pricing and inspect diagnostics
|
||||||
|
6. Build pricing snapshot
|
||||||
|
7. Inspect publish readiness and publish preview
|
||||||
|
8. Publish current draft
|
||||||
|
9. If editing is needed after publish, create or ensure a new draft again
|
||||||
|
|
||||||
|
## Main concepts
|
||||||
|
|
||||||
|
### Scheme
|
||||||
|
Top-level business entity.
|
||||||
|
|
||||||
|
### Scheme version
|
||||||
|
Concrete version of the scheme. A version can be `draft` or `published`.
|
||||||
|
|
||||||
|
### Current version
|
||||||
|
The version referenced by the scheme registry as active current.
|
||||||
|
|
||||||
|
### Draft
|
||||||
|
Editable current version. All editor mutations and draft pricing operations must target a current draft version only.
|
||||||
|
|
||||||
|
### Published version
|
||||||
|
Non-editable current version. If current version is published, editor flow must first create or ensure a new draft.
|
||||||
|
|
||||||
|
### Upload artifacts
|
||||||
|
Stored technical artifacts, including:
|
||||||
|
- original svg
|
||||||
|
- sanitized svg
|
||||||
|
- normalized json
|
||||||
|
- display svg
|
||||||
|
- publish preview json
|
||||||
|
|
||||||
|
## Editor entry flow
|
||||||
|
|
||||||
|
### 1. Inspect editor state
|
||||||
|
`GET /api/v1/schemes/{scheme_id}/editor/context`
|
||||||
|
|
||||||
|
Response tells whether:
|
||||||
|
- current version is draft
|
||||||
|
- editor is available
|
||||||
|
- a new draft should be created
|
||||||
|
- recommended action is `use_current_draft` or `create_draft`
|
||||||
|
|
||||||
|
### 2. Ensure editable draft
|
||||||
|
`POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- if current version is already draft: returns it with `created=false`
|
||||||
|
- if current version is published: clones current version into a new current draft and returns it with `created=true`
|
||||||
|
|
||||||
|
Returned `scheme_version_id` should be reused as:
|
||||||
|
- `expected_scheme_version_id`
|
||||||
|
|
||||||
|
for draft reads and mutations.
|
||||||
|
|
||||||
|
## Optimistic concurrency
|
||||||
|
|
||||||
|
Mutable draft flows support optimistic concurrency through query params:
|
||||||
|
- `expected_current_scheme_version_id`
|
||||||
|
- `expected_scheme_version_id`
|
||||||
|
|
||||||
|
Typical typed conflicts:
|
||||||
|
- `stale_current_version`
|
||||||
|
- `stale_draft_version`
|
||||||
|
- `draft_not_editable`
|
||||||
|
- `publish_not_ready`
|
||||||
|
|
||||||
|
## Main operator routes
|
||||||
|
|
||||||
|
### System
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /api/v1/ping`
|
||||||
|
- `GET /api/v1/db/ping`
|
||||||
|
- `GET /api/v1/manifest`
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
- `POST /api/v1/schemes/upload`
|
||||||
|
- `GET /api/v1/uploads`
|
||||||
|
- `GET /api/v1/uploads/{upload_id}`
|
||||||
|
- `GET /api/v1/uploads/{upload_id}/normalized`
|
||||||
|
|
||||||
|
### Scheme registry
|
||||||
|
- `GET /api/v1/schemes`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/versions`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/versions`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/publish/validation`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/publish-readiness`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/publish`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/unpublish`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/rollback`
|
||||||
|
|
||||||
|
### Editor / draft
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/editor/context`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/summary`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/structure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/validation`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/compare-preview`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/sectors`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/groups`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
|
||||||
|
- `PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/seats/bulk`
|
||||||
|
- `PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}`
|
||||||
|
- `PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/repair-references`
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/categories`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/rules`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
|
||||||
|
### Pricing diagnostics
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/coverage`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics`
|
||||||
|
|
||||||
|
### Publish preview
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/publish-preview`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/remap/preview`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/remap/apply`
|
||||||
|
|
||||||
|
### Structure read model
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/sectors`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/groups`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/seats`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/svg`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/svg/display`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta`
|
||||||
|
|
||||||
|
### Test mode
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}`
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/audit`
|
||||||
|
|
||||||
|
### Admin / ops
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/artifacts`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/validation`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate`
|
||||||
|
- `POST /api/v1/admin/display/backfill`
|
||||||
|
- `GET /api/v1/admin/artifacts/publish-preview/audit`
|
||||||
|
- `POST /api/v1/admin/artifacts/publish-preview/cleanup`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup`
|
||||||
|
|
||||||
|
## Cleanup of test pricing data
|
||||||
|
|
||||||
|
Cleanup endpoints are intended for removing diagnostic / test categories accidentally accumulated in a shared scheme.
|
||||||
|
|
||||||
|
Preview candidates:
|
||||||
|
`GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview`
|
||||||
|
|
||||||
|
Execute cleanup:
|
||||||
|
`POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup`
|
||||||
|
|
||||||
|
Safety notes:
|
||||||
|
- use `dry_run=true` first
|
||||||
|
- keep `delete_only_without_rules=true` unless you intentionally want a harder cleanup
|
||||||
|
- prefer matching by prefixes instead of raw ids for repetitive test artifacts
|
||||||
|
|
||||||
|
Helper script:
|
||||||
|
- `backend/scripts/cleanup_test_pricing_data.sh`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
`SCHEME_ID=... DRY_RUN=true ./backend/scripts/cleanup_test_pricing_data.sh`
|
||||||
|
|
||||||
|
## Typical local flow
|
||||||
|
|
||||||
|
### 1. Read current version
|
||||||
|
`GET /api/v1/schemes/{scheme_id}/current`
|
||||||
|
|
||||||
|
### 2. Ensure draft
|
||||||
|
`POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
|
||||||
|
Store returned:
|
||||||
|
- `scheme_version_id`
|
||||||
|
|
||||||
|
### 3. Read draft state
|
||||||
|
- `GET /draft/summary?expected_scheme_version_id=...`
|
||||||
|
- `GET /draft/structure?expected_scheme_version_id=...`
|
||||||
|
- `GET /draft/validation?expected_scheme_version_id=...`
|
||||||
|
- `GET /draft/compare-preview?expected_scheme_version_id=...`
|
||||||
|
|
||||||
|
### 4. Perform editor mutations
|
||||||
|
Pass:
|
||||||
|
- `expected_scheme_version_id={draft_scheme_version_id}`
|
||||||
|
|
||||||
|
on every mutation route.
|
||||||
|
|
||||||
|
### 5. Inspect pricing quality
|
||||||
|
- `GET /pricing/coverage`
|
||||||
|
- `GET /pricing/unpriced-seats`
|
||||||
|
- `GET /pricing/explain/{seat_id}`
|
||||||
|
- `GET /pricing/rules/diagnostics`
|
||||||
|
|
||||||
|
### 6. Build snapshot and inspect readiness
|
||||||
|
- `POST /draft/pricing/snapshot`
|
||||||
|
- `GET /draft/publish-readiness`
|
||||||
|
- `GET /draft/publish-preview?refresh=true`
|
||||||
|
|
||||||
|
### 7. Publish
|
||||||
|
- `POST /publish?expected_scheme_version_id=...`
|
||||||
|
|
||||||
|
## Regression
|
||||||
|
|
||||||
|
Main operator regressions:
|
||||||
|
- `backend/scripts/smoke_regression.sh`
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh`
|
||||||
|
|
||||||
|
Run:
|
||||||
|
`API_URL=http://127.0.0.1:9020 API_KEY=admin-local-dev-key SCHEME_ID=... ./backend/scripts/smoke_regression.sh`
|
||||||
|
|
||||||
|
`API_URL=http://127.0.0.1:9020 API_KEY=admin-local-dev-key SCHEME_ID=... ./backend/scripts/editor_mutation_regression.sh`
|
||||||
528
doc/frontend-integration-contract.md
Normal file
528
doc/frontend-integration-contract.md
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
# Backend Integration Contract
|
||||||
|
|
||||||
|
This document is the frontend handoff contract for the `svg-service` backend. It is written as an integration baseline, not as an internal backend README.
|
||||||
|
|
||||||
|
## 1. Base URL and Auth
|
||||||
|
|
||||||
|
- Base URL: `http://<host>:9020`
|
||||||
|
- API prefix: `/api/v1`
|
||||||
|
- Auth header: `X-API-Key`
|
||||||
|
|
||||||
|
All non-`/healthz` routes require an API key.
|
||||||
|
|
||||||
|
Auth failure contract:
|
||||||
|
|
||||||
|
- missing API key -> `401` with string detail: `Missing API key`
|
||||||
|
- invalid API key -> `403` with string detail: `Invalid API key`
|
||||||
|
- valid non-admin key on admin-only route -> `403` with string detail: `Admin role required`
|
||||||
|
|
||||||
|
## 2. Roles and Access Boundaries
|
||||||
|
|
||||||
|
- `admin`
|
||||||
|
- full access to protected routes
|
||||||
|
- required for all `/api/v1/admin/...` routes
|
||||||
|
- `operator`
|
||||||
|
- allowed on non-admin protected routes
|
||||||
|
- denied on admin-only routes
|
||||||
|
- `viewer`
|
||||||
|
- allowed on non-admin protected routes
|
||||||
|
- denied on admin-only routes
|
||||||
|
|
||||||
|
Frontend implication:
|
||||||
|
|
||||||
|
- admin UI must treat admin routes as optional capabilities gated by role
|
||||||
|
- frontend must not assume `operator` or `viewer` can call cleanup, audit, backfill, or current-artifact admin routes
|
||||||
|
|
||||||
|
## 3. Core Entities
|
||||||
|
|
||||||
|
### Upload
|
||||||
|
|
||||||
|
Represents one uploaded SVG source and its normalized/sanitized artifacts.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `upload_id`
|
||||||
|
- `original_filename`
|
||||||
|
- `content_type`
|
||||||
|
- `size_bytes`
|
||||||
|
- `original_storage_path`
|
||||||
|
- `sanitized_storage_path`
|
||||||
|
- `normalized_storage_path`
|
||||||
|
- `normalized_elements_count`
|
||||||
|
- `normalized_seats_count`
|
||||||
|
- `normalized_groups_count`
|
||||||
|
- `normalized_sectors_count`
|
||||||
|
|
||||||
|
### Scheme
|
||||||
|
|
||||||
|
Top-level business object created from upload.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `scheme_id`
|
||||||
|
- `source_upload_id`
|
||||||
|
- `name`
|
||||||
|
- `status`
|
||||||
|
- `current_version_number`
|
||||||
|
- `published_at`
|
||||||
|
|
||||||
|
### Scheme Version
|
||||||
|
|
||||||
|
Versioned snapshot of the scheme structure and publish state.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `scheme_version_id`
|
||||||
|
- `scheme_id`
|
||||||
|
- `version_number`
|
||||||
|
- `status`
|
||||||
|
- `normalized_storage_path`
|
||||||
|
- `normalized_*_count`
|
||||||
|
|
||||||
|
### Sector
|
||||||
|
|
||||||
|
Structure entity in a specific `scheme_version`.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `sector_record_id`
|
||||||
|
- `sector_id`
|
||||||
|
- `element_id`
|
||||||
|
- `name`
|
||||||
|
|
||||||
|
Business identity priority:
|
||||||
|
|
||||||
|
- use `sector_id` when present
|
||||||
|
- fallback to `element_id`
|
||||||
|
- never treat `sector_record_id` as business identity across versions
|
||||||
|
|
||||||
|
### Group
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `group_record_id`
|
||||||
|
- `group_id`
|
||||||
|
- `element_id`
|
||||||
|
- `name`
|
||||||
|
|
||||||
|
Business identity priority:
|
||||||
|
|
||||||
|
- use `group_id` when present
|
||||||
|
- fallback to `element_id`
|
||||||
|
- never treat `group_record_id` as business identity across versions
|
||||||
|
|
||||||
|
### Seat
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `seat_record_id`
|
||||||
|
- `seat_id`
|
||||||
|
- `element_id`
|
||||||
|
- `sector_id`
|
||||||
|
- `group_id`
|
||||||
|
- `row_label`
|
||||||
|
- `seat_number`
|
||||||
|
|
||||||
|
Business identity priority:
|
||||||
|
|
||||||
|
- use `seat_id` when present
|
||||||
|
- fallback to `element_id`
|
||||||
|
- never treat `seat_record_id` as business identity across versions
|
||||||
|
|
||||||
|
### Pricing Category
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `pricing_category_id`
|
||||||
|
- `scheme_id`
|
||||||
|
- `name`
|
||||||
|
- `code`
|
||||||
|
|
||||||
|
### Price Rule
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `price_rule_id`
|
||||||
|
- `scheme_id`
|
||||||
|
- `pricing_category_id`
|
||||||
|
- `target_type`
|
||||||
|
- `target_ref`
|
||||||
|
- `amount`
|
||||||
|
- `currency`
|
||||||
|
|
||||||
|
### Artifact
|
||||||
|
|
||||||
|
Artifact registry row for generated backend files.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `artifact_id`
|
||||||
|
- `artifact_type`
|
||||||
|
- `artifact_variant`
|
||||||
|
- `storage_path`
|
||||||
|
- `status`
|
||||||
|
- `meta_json`
|
||||||
|
|
||||||
|
Important artifact types currently exercised by regression:
|
||||||
|
|
||||||
|
- `sanitized_svg`
|
||||||
|
- `normalized_json`
|
||||||
|
- `display_svg`
|
||||||
|
- `publish_preview`
|
||||||
|
|
||||||
|
## 4. Lifecycle State Machine
|
||||||
|
|
||||||
|
### Fresh Upload
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. `POST /api/v1/schemes/upload`
|
||||||
|
2. backend creates:
|
||||||
|
- `upload`
|
||||||
|
- `scheme`
|
||||||
|
- initial `scheme_version`
|
||||||
|
- structure rows
|
||||||
|
- initial artifacts
|
||||||
|
|
||||||
|
Expected initial state:
|
||||||
|
|
||||||
|
- `scheme.status = draft`
|
||||||
|
- `scheme.current_version_number = 1`
|
||||||
|
- current version status = `draft`
|
||||||
|
|
||||||
|
### Current Draft
|
||||||
|
|
||||||
|
If current scheme/version is still draft:
|
||||||
|
|
||||||
|
- editor works directly against current version
|
||||||
|
- `draft/ensure` is idempotent
|
||||||
|
- `draft/ensure` returns `created=false`
|
||||||
|
|
||||||
|
### Ensure Draft From Published Current
|
||||||
|
|
||||||
|
If current scheme/version is published:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
- backend creates a new draft version
|
||||||
|
- current pointer switches to the new draft
|
||||||
|
- version number increments
|
||||||
|
|
||||||
|
### Publish
|
||||||
|
|
||||||
|
Preconditions:
|
||||||
|
|
||||||
|
- current scheme is draft
|
||||||
|
- current version is draft
|
||||||
|
- publish readiness must be satisfied
|
||||||
|
|
||||||
|
Publish path:
|
||||||
|
|
||||||
|
1. optional `draft/pricing/snapshot`
|
||||||
|
2. `GET draft/publish-readiness`
|
||||||
|
3. optional `GET draft/publish-preview`
|
||||||
|
4. `POST /api/v1/schemes/{scheme_id}/publish`
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
|
||||||
|
- scheme becomes `published`
|
||||||
|
- current version becomes `published`
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Path:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/rollback`
|
||||||
|
|
||||||
|
Effect:
|
||||||
|
|
||||||
|
- current pointer switches to requested historical `version_number`
|
||||||
|
- scheme returns to `draft`
|
||||||
|
- target version becomes current editable draft
|
||||||
|
|
||||||
|
### Unpublish
|
||||||
|
|
||||||
|
Path:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/unpublish`
|
||||||
|
|
||||||
|
Effect:
|
||||||
|
|
||||||
|
- current scheme becomes `draft`
|
||||||
|
- current version becomes `draft`
|
||||||
|
|
||||||
|
## 5. Editor Flow
|
||||||
|
|
||||||
|
### Entry Point
|
||||||
|
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/editor/context`
|
||||||
|
|
||||||
|
Use it first to decide whether:
|
||||||
|
|
||||||
|
- current draft can be edited directly
|
||||||
|
- or a new draft must be created from published current
|
||||||
|
|
||||||
|
Important response fields:
|
||||||
|
|
||||||
|
- `current_scheme_version_id`
|
||||||
|
- `current_version_number`
|
||||||
|
- `scheme_status`
|
||||||
|
- `scheme_version_status`
|
||||||
|
- `current_is_draft`
|
||||||
|
- `create_draft_available`
|
||||||
|
- `recommended_action`
|
||||||
|
|
||||||
|
### Draft Read Models
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/summary`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/structure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/validation`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/compare-preview`
|
||||||
|
|
||||||
|
Frontend should treat `draft/structure` as the main editable read model.
|
||||||
|
|
||||||
|
### Patch Operations
|
||||||
|
|
||||||
|
Supported flows:
|
||||||
|
|
||||||
|
- single seat patch
|
||||||
|
- bulk seat patch
|
||||||
|
- sector create/patch/delete
|
||||||
|
- group create/patch/delete
|
||||||
|
- repair references
|
||||||
|
- remap preview/apply
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- always send `expected_scheme_version_id` when mutating or reading draft state after editor entry
|
||||||
|
|
||||||
|
### Stale Conflict Handling
|
||||||
|
|
||||||
|
If backend returns a stale or draft editability conflict:
|
||||||
|
|
||||||
|
- stop optimistic local mutation flow
|
||||||
|
- re-read:
|
||||||
|
- `editor/context`
|
||||||
|
- `draft/summary`
|
||||||
|
- `draft/structure`
|
||||||
|
|
||||||
|
Do not keep editing against stale cached `scheme_version_id`.
|
||||||
|
|
||||||
|
## 6. Pricing Flow
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/categories`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/rules`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
|
||||||
|
### Read Models
|
||||||
|
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/coverage`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}`
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- empty pricing on a fresh upload is valid
|
||||||
|
- do not treat `categories=[]` and `rules=[]` as backend failure
|
||||||
|
|
||||||
|
## 7. Publish Flow
|
||||||
|
|
||||||
|
Main endpoints:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/publish-readiness`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/publish-preview`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/publish`
|
||||||
|
|
||||||
|
Frontend sequencing rule:
|
||||||
|
|
||||||
|
1. ensure draft
|
||||||
|
2. mutate if needed
|
||||||
|
3. create/refresh pricing
|
||||||
|
4. build pricing snapshot
|
||||||
|
5. read publish readiness
|
||||||
|
6. read publish preview if UI needs preview surface
|
||||||
|
7. publish
|
||||||
|
|
||||||
|
## 8. Admin/Ops Flow
|
||||||
|
|
||||||
|
Admin-only endpoints:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/artifacts`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/validation`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate`
|
||||||
|
- `POST /api/v1/admin/display/backfill`
|
||||||
|
- `GET /api/v1/admin/artifacts/publish-preview/audit`
|
||||||
|
- `POST /api/v1/admin/artifacts/publish-preview/cleanup`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup`
|
||||||
|
|
||||||
|
Healthy publish-preview audit contract:
|
||||||
|
|
||||||
|
- `orphan_files_count = 0`
|
||||||
|
- `missing_files_for_db_rows_count = 0`
|
||||||
|
- `db_rows_count == disk_files_count`
|
||||||
|
|
||||||
|
Frontend implication:
|
||||||
|
|
||||||
|
- admin tools must not be shown as generally available functionality
|
||||||
|
- admin cleanup/destructive flows must be role-gated on the client and still handle backend `403`
|
||||||
|
|
||||||
|
## 9. Typed Error Catalog
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- `401` string detail: `Missing API key`
|
||||||
|
- `403` string detail: `Invalid API key`
|
||||||
|
- `403` string detail: `Admin role required`
|
||||||
|
|
||||||
|
### Lifecycle / Draft / Publish
|
||||||
|
|
||||||
|
- `stale_draft_version`
|
||||||
|
- `stale_current_version`
|
||||||
|
- `current_version_inconsistent`
|
||||||
|
- `draft_not_editable`
|
||||||
|
- `publish_not_ready`
|
||||||
|
|
||||||
|
### Editor Uniqueness / References
|
||||||
|
|
||||||
|
- `editor_uniqueness_error`
|
||||||
|
- `editor_reference_error`
|
||||||
|
- `duplicate_seat_id`
|
||||||
|
- `duplicate_seat_id_in_payload`
|
||||||
|
- `duplicate_sector_id`
|
||||||
|
- `duplicate_group_id`
|
||||||
|
- `duplicate_sector_element_id`
|
||||||
|
- `duplicate_group_element_id`
|
||||||
|
- `unknown_sector_id`
|
||||||
|
- `unknown_group_id`
|
||||||
|
- `unknown_sector_ids`
|
||||||
|
- `unknown_group_ids`
|
||||||
|
- `unknown_target_sector_id`
|
||||||
|
- `unknown_target_group_id`
|
||||||
|
- `business_identifier_nullification_forbidden`
|
||||||
|
|
||||||
|
### Pricing / Remap / Test
|
||||||
|
|
||||||
|
- `invalid_amount`
|
||||||
|
- `remap_filter_required`
|
||||||
|
- `test_preview_failed`
|
||||||
|
|
||||||
|
### Validation Report Codes
|
||||||
|
|
||||||
|
These appear inside validation report payloads rather than as top-level HTTP conflict codes:
|
||||||
|
|
||||||
|
- `duplicate_seat_ids`
|
||||||
|
- `missing_seat_contract`
|
||||||
|
- `seats_without_sector_or_group`
|
||||||
|
- `seats_without_price`
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- do not parse only HTTP status
|
||||||
|
- always inspect structured `detail.code` when `detail` is an object
|
||||||
|
|
||||||
|
## 10. Frontend Obligations
|
||||||
|
|
||||||
|
- always handle auth failures `401` and `403`
|
||||||
|
- always handle stale/conflict responses on draft, publish, and lifecycle operations
|
||||||
|
- never treat `*_record_id` as stable cross-version business identity
|
||||||
|
- always prefer business ids:
|
||||||
|
- seat -> `seat_id`, fallback `element_id`
|
||||||
|
- sector -> `sector_id`, fallback `element_id`
|
||||||
|
- group -> `group_id`, fallback `element_id`
|
||||||
|
- re-read current/draft state after:
|
||||||
|
- any `409`
|
||||||
|
- publish
|
||||||
|
- rollback
|
||||||
|
- unpublish
|
||||||
|
- `draft/ensure` returning a newly created draft
|
||||||
|
- do not assume current version remains stable across concurrent operator sessions
|
||||||
|
- do not assume publish-preview artifacts or display artifacts are frontend-owned resources
|
||||||
|
|
||||||
|
## 11. Non-Persistent Assumptions Frontend Must Avoid
|
||||||
|
|
||||||
|
The frontend must not assume that these remain stable forever:
|
||||||
|
|
||||||
|
- `scheme_version_id`
|
||||||
|
- `seat_record_id`
|
||||||
|
- `sector_record_id`
|
||||||
|
- `group_record_id`
|
||||||
|
- artifact `storage_path`
|
||||||
|
- publish-preview cache artifacts
|
||||||
|
|
||||||
|
These are safe to treat as business-stable:
|
||||||
|
|
||||||
|
- `scheme_id`
|
||||||
|
- `version_number` within one scheme
|
||||||
|
- `seat_id` when present
|
||||||
|
- `sector_id` when present
|
||||||
|
- `group_id` when present
|
||||||
|
|
||||||
|
## 12. Known Limitations / Deferred Tech Debt
|
||||||
|
|
||||||
|
- some lifecycle negative contracts still return mixed styles:
|
||||||
|
- typed object conflicts for `409`
|
||||||
|
- plain string details for some `404` and auth cases
|
||||||
|
- validation warnings and error code families are not yet unified into one single global error envelope
|
||||||
|
- admin/ops routes are backend-internal tools, not end-user product APIs
|
||||||
|
- corruption remediation smoke exists only for `publish_preview`, not for every artifact type
|
||||||
|
|
||||||
|
## 13. Regression Baseline Frontend Can Rely On
|
||||||
|
|
||||||
|
The frontend can rely on the following regression-backed flows:
|
||||||
|
|
||||||
|
- fresh upload on clean DB
|
||||||
|
- current/draft/editor read flow
|
||||||
|
- editor mutations and stale draft protection
|
||||||
|
- pricing setup and publish flow
|
||||||
|
- version lifecycle:
|
||||||
|
- publish
|
||||||
|
- ensure draft from published current
|
||||||
|
- rollback
|
||||||
|
- unpublish
|
||||||
|
- admin ops:
|
||||||
|
- audit
|
||||||
|
- cleanup
|
||||||
|
- destructive pricing cleanup for safe fixture categories
|
||||||
|
- full admin permission matrix on implemented admin endpoints
|
||||||
|
- controlled `publish_preview` corruption detection and remediation
|
||||||
|
- negative upload validation
|
||||||
|
- negative auth matrix
|
||||||
|
- negative lifecycle matrix
|
||||||
|
|
||||||
|
## 14. Recommended Frontend Integration Sequence
|
||||||
|
|
||||||
|
For normal editor work:
|
||||||
|
|
||||||
|
1. authenticate
|
||||||
|
2. upload or pick `scheme_id`
|
||||||
|
3. read `editor/context`
|
||||||
|
4. call `draft/ensure` if needed
|
||||||
|
5. read `draft/structure`
|
||||||
|
6. mutate using current `scheme_version_id`
|
||||||
|
7. on `409`, reload editor state before retry
|
||||||
|
8. configure pricing if needed
|
||||||
|
9. create pricing snapshot
|
||||||
|
10. read publish readiness / preview
|
||||||
|
11. publish
|
||||||
|
|
||||||
|
For admin UI:
|
||||||
|
|
||||||
|
1. verify admin role in client auth state
|
||||||
|
2. call admin endpoints
|
||||||
|
3. still handle backend `403`
|
||||||
|
4. treat cleanup and remediation as explicit operator actions, not background automation
|
||||||
673
doc/smoke-regression.md
Normal file
673
doc/smoke-regression.md
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
# Smoke regression checklist
|
||||||
|
|
||||||
|
This file is the backend manual regression baseline for svg-service.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- docker compose stack is up
|
||||||
|
- backend responds on port 9020
|
||||||
|
- valid admin API key is available
|
||||||
|
- stable SVG fixture exists in repository, e.g. `sample-contract.svg`
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Use these variables in shell:
|
||||||
|
|
||||||
|
export API_URL="http://127.0.0.1:9020"
|
||||||
|
export API_KEY="admin-local-dev-key"
|
||||||
|
export FIXTURE_SVG_PATH="/home/adminko/svg-service/sample-contract.svg"
|
||||||
|
|
||||||
|
## Active regression contour
|
||||||
|
|
||||||
|
Primary operator regressions:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_core.sh`
|
||||||
|
- `backend/scripts/smoke_pricing_publish.sh`
|
||||||
|
- `backend/scripts/smoke_version_lifecycle.sh`
|
||||||
|
- `backend/scripts/smoke_lifecycle_negative.sh`
|
||||||
|
- `backend/scripts/smoke_admin_ops.sh`
|
||||||
|
- `backend/scripts/smoke_auth_negative.sh`
|
||||||
|
- `backend/scripts/smoke_authz_admin_all.sh`
|
||||||
|
- `backend/scripts/smoke_artifact_corruption.sh`
|
||||||
|
- `backend/scripts/smoke_upload_negative.sh`
|
||||||
|
- `backend/scripts/smoke_regression.sh`
|
||||||
|
|
||||||
|
Only this set is part of the active backend regression contour.
|
||||||
|
|
||||||
|
The scripts are expected to fail fast on any contract break or unexpected 5xx.
|
||||||
|
|
||||||
|
`smoke_regression.sh` is now an orchestration wrapper:
|
||||||
|
|
||||||
|
- first runs `smoke_core.sh`
|
||||||
|
- then runs `smoke_pricing_publish.sh`
|
||||||
|
- then runs `smoke_version_lifecycle.sh`
|
||||||
|
- then runs `smoke_lifecycle_negative.sh`
|
||||||
|
- then runs `smoke_admin_ops.sh`
|
||||||
|
- then runs `smoke_authz_admin_all.sh`
|
||||||
|
- then runs `smoke_auth_negative.sh`
|
||||||
|
- then runs `smoke_artifact_corruption.sh`
|
||||||
|
- then runs `smoke_upload_negative.sh`
|
||||||
|
- returns non-zero if any scenario fails
|
||||||
|
|
||||||
|
## Standalone/manual scripts
|
||||||
|
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh`
|
||||||
|
- `backend/scripts/cleanup_test_pricing_data.sh`
|
||||||
|
|
||||||
|
These scripts are intentionally not called by `smoke_regression.sh`.
|
||||||
|
|
||||||
|
## Scenario split
|
||||||
|
|
||||||
|
### Core smoke on clean DB
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_core.sh`
|
||||||
|
|
||||||
|
This scenario is designed for a fully clean database.
|
||||||
|
|
||||||
|
It uploads a fresh SVG fixture, resolves the created `scheme_id`, validates current/draft read models, validates empty pricing state, and then runs `editor_mutation_regression.sh` on the same fresh scheme.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it does not require pre-existing `scheme_id`
|
||||||
|
- it does not require pricing categories or price rules
|
||||||
|
- it does not require publish snapshot or published baseline
|
||||||
|
- empty pricing on a fresh upload is a valid state, not a failure
|
||||||
|
|
||||||
|
### Pricing/publish smoke with fixture setup
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_pricing_publish.sh`
|
||||||
|
|
||||||
|
This scenario also uploads a fresh SVG fixture, then prepares its own pricing fixture before validating pricing and publish flow.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it creates its own pricing category
|
||||||
|
- it creates its own pricing rule
|
||||||
|
- it intentionally checks both a priced seat and an unpriced seat on the same fresh scheme
|
||||||
|
- it does not rely on historical pricing IDs, rules, or old schemes
|
||||||
|
|
||||||
|
### Version lifecycle smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_version_lifecycle.sh`
|
||||||
|
|
||||||
|
This scenario uploads a fresh SVG, publishes version 1, creates version 2 from published current, mutates the new draft, publishes version 2, rolls back to version 1, and then runs unpublish on the current scheme.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it validates multi-version lifecycle beyond fresh upload
|
||||||
|
- it checks that `draft/ensure` creates a new draft only after current becomes published
|
||||||
|
- it verifies rollback switches `current_version_number` to the requested target version
|
||||||
|
- it verifies the rolled-back current structure matches the target version semantics, not the later mutated draft
|
||||||
|
- it checks audit trail for `scheme.published`, `scheme.version.created`, `scheme.rolled_back`, and `scheme.unpublished`
|
||||||
|
|
||||||
|
### Lifecycle negative smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_lifecycle_negative.sh`
|
||||||
|
|
||||||
|
This scenario uses fresh disposable scheme data to verify negative lifecycle contracts without leaving the database in a broken state.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it checks rollback to a nonexistent version
|
||||||
|
- it checks stale current-version guards on `draft/ensure`
|
||||||
|
- it checks stale expected-version guards on `publish`
|
||||||
|
- it creates a temporary `current_version_inconsistent` pointer only inside the scenario and restores it before exit
|
||||||
|
|
||||||
|
### Admin/ops smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_admin_ops.sh`
|
||||||
|
|
||||||
|
This scenario uploads a fresh SVG and prepares its own admin-cleanup fixture inside the scenario before checking current-artifact inspection, validation, publish-preview audit/cleanup, and pricing-category cleanup preview/dry-run.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it creates its own pricing categories for cleanup preview
|
||||||
|
- it creates its own protected pricing rule so cleanup preview has both deletable and skipped categories
|
||||||
|
- it does not rely on historical orphan artifacts, old schemes, or dirty pricing state
|
||||||
|
- it checks publish-preview cleanup in both dry-run and execute modes
|
||||||
|
- it requires the final publish-preview audit state to be healthy: `orphan_files_count=0` and `missing_files_for_db_rows_count=0`
|
||||||
|
- it executes destructive pricing cleanup only for self-created safe fixture data
|
||||||
|
|
||||||
|
### Admin authz smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_authz_admin_all.sh`
|
||||||
|
|
||||||
|
This scenario uploads a fresh SVG, prepares its own cleanup fixture data, and then checks permission boundaries for admin/operator/viewer on all currently implemented admin endpoints used by the regression contour.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- admin must be allowed on tested admin endpoints
|
||||||
|
- operator and viewer must be denied with controlled 403 responses
|
||||||
|
- the scenario does not rely on historical scheme ids or dirty pricing state
|
||||||
|
- destructive pricing cleanup execution is validated with fresh self-created fixture categories only
|
||||||
|
|
||||||
|
### Artifact corruption smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_artifact_corruption.sh`
|
||||||
|
|
||||||
|
This scenario creates fresh publish-preview artifacts and then simulates two controlled corruption cases only on the artifacts created inside the scenario.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- case A removes a preview file while leaving its DB row in place
|
||||||
|
- case B removes a preview DB row while leaving its file on disk
|
||||||
|
- audit must detect both inconsistencies correctly
|
||||||
|
- cleanup dry-run must stay readable and non-destructive
|
||||||
|
- cleanup execute must remediate the introduced inconsistency
|
||||||
|
- the scenario does not touch historical schemes or unrelated artifact rows/files
|
||||||
|
|
||||||
|
### Auth negative smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_auth_negative.sh`
|
||||||
|
|
||||||
|
This scenario checks the negative auth matrix on a representative route set.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- missing API key must return `401`
|
||||||
|
- invalid API key must return `403`
|
||||||
|
- valid non-admin key must return `403` only on admin-only endpoints
|
||||||
|
- the route set includes protected, editor, pricing, admin, and admin-cleanup endpoints
|
||||||
|
|
||||||
|
### Negative upload smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_upload_negative.sh`
|
||||||
|
|
||||||
|
This scenario checks controlled upload failures for invalid inputs.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- empty upload must fail with a controlled 4xx
|
||||||
|
- non-SVG uploads must fail with a controlled 4xx
|
||||||
|
- invalid extension/content-type combinations must fail with a controlled 4xx
|
||||||
|
- oversize upload must fail with a controlled 413 when the configured size limit is exceeded
|
||||||
|
- no negative case is allowed to return 500
|
||||||
|
|
||||||
|
## 1. Health / system
|
||||||
|
|
||||||
|
- GET /healthz -> 200 (smoke uses a bounded retry/wait loop and fails explicitly if the API never becomes ready)
|
||||||
|
- GET /api/v1/ping -> 200
|
||||||
|
- GET /api/v1/db/ping -> 200
|
||||||
|
- GET /api/v1/manifest -> 200
|
||||||
|
|
||||||
|
## 2. Core smoke coverage
|
||||||
|
|
||||||
|
`smoke_core.sh` checks:
|
||||||
|
|
||||||
|
- GET /healthz -> 200
|
||||||
|
- GET /api/v1/ping -> 200
|
||||||
|
- GET /api/v1/db/ping -> 200
|
||||||
|
- GET /api/v1/manifest -> 200
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET /api/v1/schemes -> 200 and resolves the fresh `scheme_id`
|
||||||
|
- GET /api/v1/schemes/{scheme_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/versions -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context -> 200
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/summary -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/structure -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/validation -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/compare-preview -> 200
|
||||||
|
- GET draft entities by record id -> 200
|
||||||
|
- stale `expected_scheme_version_id` conflict -> 409 with typed `stale_draft_version`
|
||||||
|
- GET current sectors/groups/seats -> 200
|
||||||
|
- GET current SVG display meta -> 200
|
||||||
|
- GET pricing bundle -> 200 with empty categories/rules
|
||||||
|
- GET pricing coverage -> 200 with zero priced seats
|
||||||
|
- GET pricing explain/{seat_id} -> 200 with `no_price_rule`
|
||||||
|
- GET pricing rules diagnostics -> 200 with empty state
|
||||||
|
- GET audit -> 200
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh` on the same fresh scheme
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- fresh upload is readable immediately through current/draft/editor endpoints
|
||||||
|
- empty pricing is accepted as normal state for a newly uploaded scheme
|
||||||
|
- no endpoint in core smoke returns 500
|
||||||
|
|
||||||
|
## 3. Pricing/publish smoke coverage
|
||||||
|
|
||||||
|
`smoke_pricing_publish.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET current / POST draft ensure on the fresh scheme -> 200
|
||||||
|
- POST pricing category -> 200
|
||||||
|
- POST price rule -> 200
|
||||||
|
- GET pricing bundle -> 200 with created fixture data
|
||||||
|
- GET pricing coverage -> 200 with both priced and unpriced seats present
|
||||||
|
- GET pricing explain/{priced_seat_id} -> 200 with matched rule
|
||||||
|
- GET pricing explain/{unpriced_seat_id} -> 200 with `no_price_rule`
|
||||||
|
- GET current/seats/{priced_seat_id}/price -> 200
|
||||||
|
- GET test/seats/{priced_seat_id} -> 200
|
||||||
|
- GET test/seats/{unpriced_seat_id} -> 200
|
||||||
|
- POST draft/pricing/snapshot -> 200
|
||||||
|
- GET draft/publish-readiness -> 200
|
||||||
|
- GET draft/publish-preview?refresh=true -> 200
|
||||||
|
- GET draft/publish-preview -> 200
|
||||||
|
- POST publish -> 200
|
||||||
|
- GET scheme detail/current after publish -> 200 and published state
|
||||||
|
- GET audit -> 200 and contains `scheme.published`
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- fixture setup is fully self-contained
|
||||||
|
- priced-seat checks happen only after explicit pricing fixture creation
|
||||||
|
- publish flow is validated on a fresh scheme, not on historical DB data
|
||||||
|
|
||||||
|
## 4. Version lifecycle smoke coverage
|
||||||
|
|
||||||
|
`smoke_version_lifecycle.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET scheme detail/current immediately after upload -> version 1 draft
|
||||||
|
- POST draft ensure on version 1 -> 200 and remains same draft
|
||||||
|
- POST pricing category/rule fixture -> 200
|
||||||
|
- POST draft/pricing/snapshot on version 1 -> 200
|
||||||
|
- POST publish on version 1 -> 200
|
||||||
|
- POST draft ensure from published current -> 200 and creates version 2
|
||||||
|
- PATCH one draft seat field on version 2 -> 200
|
||||||
|
- GET draft compare-preview on version 2 -> 200 and shows changed state
|
||||||
|
- POST draft/pricing/snapshot on version 2 -> 200
|
||||||
|
- POST publish on version 2 -> 200
|
||||||
|
- POST rollback to version 1 -> 200
|
||||||
|
- POST unpublish current -> 200
|
||||||
|
- GET audit -> 200 with lifecycle events present
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- version numbering advances from 1 to 2 only when current was published
|
||||||
|
- current pointer tracks the published version before rollback
|
||||||
|
- rollback switches current pointer back to the requested target version
|
||||||
|
- rolled-back current structure matches version 1 semantics after version 2 mutation
|
||||||
|
- lifecycle audit events are present and JSON-serializable
|
||||||
|
|
||||||
|
## 5. Lifecycle negative smoke coverage
|
||||||
|
|
||||||
|
`smoke_lifecycle_negative.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET current on the fresh scheme -> 200
|
||||||
|
- POST rollback with nonexistent `target_version_number` -> controlled 404
|
||||||
|
- POST draft/ensure with stale `expected_current_scheme_version_id` -> typed 409
|
||||||
|
- POST publish with stale `expected_scheme_version_id` -> typed 409
|
||||||
|
- GET current after temporary `current_version_inconsistent` pointer corruption -> typed 409
|
||||||
|
- GET current again after scenario restoration -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- rollback to missing version stays controlled and non-500
|
||||||
|
- ensure-draft stale current pointer returns typed `stale_current_version`
|
||||||
|
- publish stale expected version stays controlled and non-500
|
||||||
|
- temporary pointer inconsistency returns typed `current_version_inconsistent`
|
||||||
|
- the temporary inconsistency is restored before the scenario exits
|
||||||
|
## 6. Admin/ops smoke coverage
|
||||||
|
|
||||||
|
`smoke_admin_ops.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- POST draft ensure on the fresh scheme -> 200
|
||||||
|
- POST pricing category fixture for cleanup preview -> 200
|
||||||
|
- POST protected pricing rule fixture -> 200
|
||||||
|
- POST draft/pricing/snapshot -> 200
|
||||||
|
- GET draft/publish-preview?refresh=true -> 200
|
||||||
|
- GET draft/publish-preview -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit -> 200
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false -> 200
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit after cleanup -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing after destructive cleanup -> 200
|
||||||
|
- repeated cleanup preview/dry-run after destructive cleanup -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- admin artifact listing stays readable for current draft version
|
||||||
|
- admin validation stays readable for current draft version
|
||||||
|
- publish-preview cleanup dry-run stays non-destructive and mirrors pre-clean audit counts
|
||||||
|
- publish-preview cleanup execute removes all orphan preview files and missing DB rows
|
||||||
|
- final publish-preview audit is strict healthy state: `orphan_files_count=0`, `missing_files_for_db_rows_count=0`, and `db_rows_count == disk_files_count`
|
||||||
|
- pricing cleanup preview identifies both deletable and protected categories created inside the scenario
|
||||||
|
- pricing cleanup dry-run never mutates fixture data
|
||||||
|
- destructive pricing cleanup deletes only the safe category without rules
|
||||||
|
- protected pricing category and its rule remain after destructive cleanup
|
||||||
|
- repeated cleanup state remains stable after destructive cleanup
|
||||||
|
|
||||||
|
## 7. Admin authz smoke coverage
|
||||||
|
|
||||||
|
`smoke_authz_admin_all.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- POST draft ensure on the fresh scheme -> 200
|
||||||
|
- POST pricing fixture categories/rule for cleanup authz checks -> 200
|
||||||
|
- POST draft/publish-preview refresh fixture -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts as admin -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts as operator/viewer -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation as admin -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate as admin -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/display/backfill as admin -> 200
|
||||||
|
- POST /api/v1/admin/display/backfill as operator/viewer -> 403
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit as admin -> 200
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true as admin -> 200
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true as operator/viewer -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview as admin -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true as admin -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false as admin -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- expected role matrix is explicit and enforced
|
||||||
|
- admin endpoints stay available to admin
|
||||||
|
- operator and viewer are denied without 500
|
||||||
|
- destructive cleanup execution remains constrained to self-created safe fixture data
|
||||||
|
|
||||||
|
## 8. Auth negative smoke coverage
|
||||||
|
|
||||||
|
`smoke_auth_negative.sh` checks:
|
||||||
|
|
||||||
|
- GET /api/v1/manifest without API key -> 401
|
||||||
|
- GET /api/v1/manifest with invalid API key -> 403
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context without API key -> 401
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context with invalid API key -> 403
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing without API key -> 401
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing with invalid API key -> 403
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit without API key -> 401
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit with invalid API key -> 403
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit with valid viewer key -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview without API key -> 401
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview with invalid API key -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview with valid viewer key -> 403
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- missing key contract is consistently `401`
|
||||||
|
- invalid key contract is consistently `403`
|
||||||
|
- valid non-admin key is denied only on admin-only endpoints
|
||||||
|
|
||||||
|
## 9. Artifact corruption smoke coverage
|
||||||
|
|
||||||
|
`smoke_artifact_corruption.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- POST draft ensure on the fresh scheme -> 200
|
||||||
|
- GET initial /api/v1/admin/artifacts/publish-preview/audit -> healthy 200
|
||||||
|
- case A: manually delete fresh preview file while keeping DB row
|
||||||
|
- GET audit after case A -> reports exactly one missing file for DB row
|
||||||
|
- POST cleanup dry_run=true after case A -> 200
|
||||||
|
- POST cleanup dry_run=false after case A -> 200 and deletes the broken DB row
|
||||||
|
- case B: manually delete fresh preview DB row while keeping file
|
||||||
|
- GET audit after case B -> reports exactly one orphan file
|
||||||
|
- POST cleanup dry_run=true after case B -> 200
|
||||||
|
- POST cleanup dry_run=false after case B -> 200 and deletes the orphan file
|
||||||
|
- final audit -> healthy 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- audit sees DB-row-without-file and file-without-DB-row separately and correctly
|
||||||
|
- dry-run remains readable and non-destructive in both corruption cases
|
||||||
|
- execute cleanup remediates only the inconsistency introduced in the scenario
|
||||||
|
- final audit is healthy again: `orphan_files_count=0`, `missing_files_for_db_rows_count=0`
|
||||||
|
|
||||||
|
## 10. Negative upload smoke coverage
|
||||||
|
|
||||||
|
`smoke_upload_negative.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload with empty SVG body -> controlled 400
|
||||||
|
- POST /api/v1/schemes/upload with non-SVG text/plain body -> controlled 400
|
||||||
|
- POST /api/v1/schemes/upload with SVG body but invalid extension/content-type pair -> controlled 400
|
||||||
|
- POST /api/v1/schemes/upload with body larger than manifest max_file_size_bytes -> controlled 413
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- upload validation rejects bad inputs with explicit 4xx contracts
|
||||||
|
- configured max file size is read from manifest, not hardcoded in the script
|
||||||
|
- no negative upload case returns 500
|
||||||
|
|
||||||
|
## 11. Legacy endpoint families
|
||||||
|
|
||||||
|
The sections below remain the API baseline by area, but regression execution is now split between clean-DB core smoke and pricing/publish smoke.
|
||||||
|
|
||||||
|
## 5. Scheme registry
|
||||||
|
|
||||||
|
- GET /api/v1/schemes -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/versions -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- scheme_id is stable
|
||||||
|
- current version exists
|
||||||
|
- version list contains current version
|
||||||
|
- status and counts are consistent
|
||||||
|
|
||||||
|
## 6. Editor entry flow
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context -> 200
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- editor context returns current_scheme_version_id
|
||||||
|
- editor context distinguishes draft vs published state correctly
|
||||||
|
- ensure endpoint is idempotent on current draft
|
||||||
|
- ensure endpoint creates a new draft from published current when needed
|
||||||
|
- returned scheme_version_id is reusable as expected_scheme_version_id
|
||||||
|
|
||||||
|
## 7. Draft read model
|
||||||
|
|
||||||
|
Using current draft version id from draft/ensure:
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/summary?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/structure?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/validation?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/compare-preview?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- summary returns total_seats / total_sectors / total_groups
|
||||||
|
- summary returns validation_summary / structure_diff_summary / publish_readiness
|
||||||
|
- structure returns lists for seats / sectors / groups
|
||||||
|
- validation is deterministic
|
||||||
|
- compare preview returns stable diff structure
|
||||||
|
- stale expected_scheme_version_id returns typed 409 conflict
|
||||||
|
|
||||||
|
## 8. Draft entity reads
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- record endpoints return exact draft entities
|
||||||
|
- unknown record id returns 404
|
||||||
|
- stale expected_scheme_version_id returns typed 409 conflict
|
||||||
|
|
||||||
|
## 9. Structure read model
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/sectors -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/groups -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/seats -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- total counts are non-negative
|
||||||
|
- known sample scheme returns expected object lists
|
||||||
|
- seats contain seat_id / sector_id / group_id contract where applicable
|
||||||
|
|
||||||
|
## 10. SVG / display pipeline
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/svg -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/svg/display -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/svg/display?mode=optimized -> 200 or explicit controlled failure
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta?mode=optimized -> 200 or explicit controlled failure
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- response content type for svg endpoints is image/svg+xml
|
||||||
|
- meta returns scheme_id, scheme_version_id, view_box, width, height
|
||||||
|
- no 500 on passthrough mode
|
||||||
|
- unsupported mode returns 422
|
||||||
|
|
||||||
|
## 11. Pricing read model
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/coverage -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 only after pricing fixture exists
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for known seat
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- fresh clean upload is allowed to have `categories=[]` and `rules=[]`
|
||||||
|
- fresh clean upload is allowed to have zero priced seats and `no_price_rule` explanations
|
||||||
|
- priced seat checks belong to pricing/publish smoke after fixture setup
|
||||||
|
- diagnostics returns stable empty state with zero rules on clean upload
|
||||||
|
- diagnostics returns matched seat visibility after fixture setup
|
||||||
|
- priced test seat amount is serialized as string when pricing exists
|
||||||
|
|
||||||
|
## 12. Draft mutation regression
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh`
|
||||||
|
|
||||||
|
This script checks:
|
||||||
|
- create sector
|
||||||
|
- create group
|
||||||
|
- patch seat
|
||||||
|
- bulk seat update
|
||||||
|
- patch sector
|
||||||
|
- patch group
|
||||||
|
- duplicate entity validation paths
|
||||||
|
- stale draft conflict
|
||||||
|
- remap preview validation path
|
||||||
|
- repair references
|
||||||
|
- delete created sector/group
|
||||||
|
- post-mutation read-model consistency
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- created entities are returned by API
|
||||||
|
- patched draft records are actually changed
|
||||||
|
- bulk update changes persisted fields
|
||||||
|
- duplicate ids return 422
|
||||||
|
- stale expected_scheme_version_id returns typed 409
|
||||||
|
- remap preview without filters returns typed 422
|
||||||
|
- post-mutation summary / validation / compare-preview remain readable and deterministic
|
||||||
|
|
||||||
|
## 13. Draft publish preview
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot -> 200 when scheme is in draft
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true&baseline_scheme_version_id={published_version_id} -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- refresh and cached read both succeed
|
||||||
|
- preview summary contains is_publishable / has_structure_changes / has_artifacts / snapshot_available
|
||||||
|
- pricing_coverage is internally consistent
|
||||||
|
- baseline override returns override strategy when explicit baseline is provided
|
||||||
|
- preview retention does not grow unbounded for same version+variant
|
||||||
|
|
||||||
|
## 14. Publish readiness and publish flow
|
||||||
|
|
||||||
|
For current draft version:
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id={draft_version_id} -> 200 or 409
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- readiness explicitly shows snapshot_available and pricing gate state
|
||||||
|
- publish with stale expected version returns typed 409
|
||||||
|
- publish without draft state returns typed 409
|
||||||
|
- publish success updates current status to published
|
||||||
|
- audit trail contains scheme.published event
|
||||||
|
|
||||||
|
## 15. Admin / ops
|
||||||
|
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit -> 200
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- artifact audit does not report orphan files or missing files for DB rows in normal state
|
||||||
|
- healthy publish-preview audit is strict: `orphan_files_count=0` and `missing_files_for_db_rows_count=0`
|
||||||
|
- validation report is readable and deterministic
|
||||||
|
- pricing cleanup preview returns matched candidates and safe_to_delete_count
|
||||||
|
- pricing cleanup dry-run returns deleted_count=0
|
||||||
|
- destructive pricing cleanup deletes only safe fixture categories without rules
|
||||||
|
- admin role is allowed on admin endpoints
|
||||||
|
- operator/viewer are denied with controlled 403 on admin endpoints
|
||||||
|
- idempotent cleanup is valid in both states: `matched_total=0` with `would_delete_count=0`, or `matched_total>0` with `would_delete_count>0`
|
||||||
|
- smoke does not require cleanup dry-run to always find something to delete
|
||||||
|
- admin routes do not produce 500 for healthy scheme state
|
||||||
|
|
||||||
|
## 16. Audit trail
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/audit -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- recent publish preview / pricing / version / publish events are present when corresponding operations were run
|
||||||
|
- audit total is non-negative
|
||||||
|
- event payloads stay JSON-serializable
|
||||||
|
|
||||||
|
## 17. Fail criteria
|
||||||
|
|
||||||
|
Regression is considered failed if any of the following happen:
|
||||||
|
|
||||||
|
- health or db ping fails
|
||||||
|
- any stable read endpoint returns 500
|
||||||
|
- passthrough display endpoint fails on known-good sample
|
||||||
|
- publish preview refresh or cached read returns 500
|
||||||
|
- publish readiness returns 500
|
||||||
|
- editor context or draft ensure returns 500
|
||||||
|
- draft summary / structure / validation / compare-preview returns 500
|
||||||
|
- editor mutation regression returns non-zero exit code
|
||||||
|
- clean upload empty pricing state is treated as a failure
|
||||||
|
- pricing bundle or diagnostics contract changes unexpectedly
|
||||||
|
- admin audit/cleanup endpoints fail on healthy environment
|
||||||
|
- pricing cleanup dry-run mutates data
|
||||||
|
- artifact retention grows without bound for repeated preview refresh on same variant
|
||||||
|
|
||||||
|
## 18. Operator note
|
||||||
|
|
||||||
|
Run this checklist after:
|
||||||
|
- schema changes
|
||||||
|
- pricing schema/repository refactors
|
||||||
|
- artifact lifecycle changes
|
||||||
|
- display pipeline changes
|
||||||
|
- route reorganization
|
||||||
|
- startup/import/config changes
|
||||||
|
- draft lifecycle changes
|
||||||
|
- publish readiness changes
|
||||||
|
- admin cleanup changes
|
||||||
|
- editor mutation changes
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import type { AxiosRequestConfig } from "axios";
|
||||||
import { getAppConfig } from "../shared/config/env";
|
import { getAppConfig } from "../shared/config/env";
|
||||||
|
|
||||||
const config = getAppConfig();
|
const config = getAppConfig();
|
||||||
@@ -10,3 +11,23 @@ export const apiClient = axios.create({
|
|||||||
"X-API-Key": config.apiKey
|
"X-API-Key": config.apiKey
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export async function apiRequest<T>(requestConfig: AxiosRequestConfig): Promise<T> {
|
||||||
|
return (await apiClient.request<T>(requestConfig)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiGet<T>(url: string, requestConfig?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return apiRequest<T>({ ...requestConfig, method: "get", url });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPost<T>(url: string, data?: unknown, requestConfig?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return apiRequest<T>({ ...requestConfig, method: "post", url, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPut<T>(url: string, data?: unknown, requestConfig?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return apiRequest<T>({ ...requestConfig, method: "put", url, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiDelete<T>(url: string, requestConfig?: AxiosRequestConfig): Promise<T> {
|
||||||
|
return apiRequest<T>({ ...requestConfig, method: "delete", url });
|
||||||
|
}
|
||||||
|
|||||||
250
src/api/errors.ts
Normal file
250
src/api/errors.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import type { ApiErrorDetailObject, ApiErrorPayload, ApiValidationFieldError } from "../shared/types/api";
|
||||||
|
|
||||||
|
export type NormalizedApiError = {
|
||||||
|
status: number | null;
|
||||||
|
code: string | null;
|
||||||
|
message: string;
|
||||||
|
detailMessage: string | null;
|
||||||
|
detailString: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
isAuthError: boolean;
|
||||||
|
isForbidden: boolean;
|
||||||
|
isAdminRequired: boolean;
|
||||||
|
isNotFound: boolean;
|
||||||
|
isConflict: boolean;
|
||||||
|
isValidation: boolean;
|
||||||
|
requiresReread: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REREAD_REQUIRED_CONFLICT_CODES = new Set([
|
||||||
|
"stale_draft_version",
|
||||||
|
"stale_current_version",
|
||||||
|
"current_version_inconsistent",
|
||||||
|
"draft_not_editable"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDetailObject(value: unknown): value is ApiErrorDetailObject {
|
||||||
|
return isObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFieldError(result: Record<string, string>, field: string, message: string) {
|
||||||
|
if (!field || !message || result[field]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result[field] = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFieldErrorsFromArray(result: Record<string, string>, items: unknown[]) {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (!isObject(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const field = typeof item.field === "string" ? item.field : null;
|
||||||
|
const message = typeof item.message === "string" ? item.message : null;
|
||||||
|
if (field && message) {
|
||||||
|
pushFieldError(result, field, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFieldErrors(payload: ApiErrorPayload | undefined): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.errors)) {
|
||||||
|
collectFieldErrorsFromArray(result, payload.errors as ApiValidationFieldError[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = payload.detail;
|
||||||
|
if (!isDetailObject(detail) || !isObject(detail.details)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedErrors = detail.details.errors;
|
||||||
|
if (Array.isArray(nestedErrors)) {
|
||||||
|
collectFieldErrorsFromArray(result, nestedErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(detail.details).forEach(([field, value]) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
pushFieldError(result, field, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
||||||
|
pushFieldError(result, field, value.join(", "));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDetail(payload: ApiErrorPayload | undefined): {
|
||||||
|
code: string | null;
|
||||||
|
detailMessage: string | null;
|
||||||
|
detailString: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
} {
|
||||||
|
if (!payload) {
|
||||||
|
return { code: null, detailMessage: null, detailString: null, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.detail === "string") {
|
||||||
|
return {
|
||||||
|
code: null,
|
||||||
|
detailMessage: payload.detail,
|
||||||
|
detailString: payload.detail,
|
||||||
|
details: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDetailObject(payload.detail)) {
|
||||||
|
return {
|
||||||
|
code: typeof payload.detail.code === "string" ? payload.detail.code : null,
|
||||||
|
detailMessage: typeof payload.detail.message === "string" ? payload.detail.message : null,
|
||||||
|
detailString: null,
|
||||||
|
details: isObject(payload.detail.details) ? payload.detail.details : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: null, detailMessage: null, detailString: null, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackMessage(status: number | null, detailMessage: string | null): string {
|
||||||
|
if (detailMessage) {
|
||||||
|
return detailMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 400) return "Некорректный запрос";
|
||||||
|
if (status === 401) return "Отсутствует API key";
|
||||||
|
if (status === 403) return "Доступ запрещён";
|
||||||
|
if (status === 404) return "Ресурс не найден";
|
||||||
|
if (status === 409) return "Конфликт состояния. Требуется reread.";
|
||||||
|
if (status === 413) return "Файл превышает допустимый лимит";
|
||||||
|
if (status === 422) return "Ошибка валидации";
|
||||||
|
if (status && status >= 500) return "Backend вернул внутреннюю ошибку";
|
||||||
|
return "Ошибка запроса к backend";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeApiError(error: unknown): NormalizedApiError {
|
||||||
|
if (axios.isAxiosError<ApiErrorPayload>(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
const message =
|
||||||
|
error.code === "ECONNABORTED"
|
||||||
|
? "Таймаут запроса к backend"
|
||||||
|
: "Сетевой сбой или блокировка cross-origin запроса";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
code: null,
|
||||||
|
message,
|
||||||
|
detailMessage: null,
|
||||||
|
detailString: null,
|
||||||
|
details: null,
|
||||||
|
fieldErrors: {},
|
||||||
|
isAuthError: false,
|
||||||
|
isForbidden: false,
|
||||||
|
isAdminRequired: false,
|
||||||
|
isNotFound: false,
|
||||||
|
isConflict: false,
|
||||||
|
isValidation: false,
|
||||||
|
requiresReread: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = error.response.data;
|
||||||
|
const status = error.response.status ?? null;
|
||||||
|
const detail = extractDetail(payload);
|
||||||
|
const detailMessage = detail.detailMessage ?? detail.detailString;
|
||||||
|
const fieldErrors = parseFieldErrors(payload);
|
||||||
|
const message = getFallbackMessage(status, detailMessage);
|
||||||
|
const isAdminRequired = detail.detailString === "Admin role required";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
code: detail.code,
|
||||||
|
message,
|
||||||
|
detailMessage,
|
||||||
|
detailString: detail.detailString,
|
||||||
|
details: detail.details,
|
||||||
|
fieldErrors,
|
||||||
|
isAuthError: status === 401 || detail.detailString === "Missing API key" || detail.detailString === "Invalid API key",
|
||||||
|
isForbidden: status === 403,
|
||||||
|
isAdminRequired,
|
||||||
|
isNotFound: status === 404,
|
||||||
|
isConflict: status === 409,
|
||||||
|
isValidation: status === 422,
|
||||||
|
requiresReread: status === 409 && (detail.code ? REREAD_REQUIRED_CONFLICT_CODES.has(detail.code) : true)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : "Неизвестная ошибка";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
code: null,
|
||||||
|
message,
|
||||||
|
detailMessage: null,
|
||||||
|
detailString: null,
|
||||||
|
details: null,
|
||||||
|
fieldErrors: {},
|
||||||
|
isAuthError: false,
|
||||||
|
isForbidden: false,
|
||||||
|
isAdminRequired: false,
|
||||||
|
isNotFound: false,
|
||||||
|
isConflict: false,
|
||||||
|
isValidation: false,
|
||||||
|
requiresReread: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiErrorMessage(error: unknown): string {
|
||||||
|
return normalizeApiError(error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiFieldErrors(error: unknown): Record<string, string> {
|
||||||
|
return normalizeApiError(error).fieldErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthError(error: unknown): boolean {
|
||||||
|
return normalizeApiError(error).isAuthError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForbiddenError(error: unknown): boolean {
|
||||||
|
return normalizeApiError(error).isForbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminRequiredError(error: unknown): boolean {
|
||||||
|
return normalizeApiError(error).isAdminRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTypedConflict(error: unknown, code?: string): boolean {
|
||||||
|
const normalized = normalizeApiError(error);
|
||||||
|
return normalized.isConflict && (code ? normalized.code === code : Boolean(normalized.code));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRereadRequiredConflict(error: unknown): boolean {
|
||||||
|
return normalizeApiError(error).requiresReread;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getControlledErrorTitle(error: unknown): string {
|
||||||
|
const normalized = normalizeApiError(error);
|
||||||
|
|
||||||
|
if (normalized.status === 401) return "Ошибка 401";
|
||||||
|
if (normalized.status === 403 && normalized.isAdminRequired) return "Ошибка 403: admin required";
|
||||||
|
if (normalized.status === 403) return "Ошибка 403";
|
||||||
|
if (normalized.status === 404) return "Ошибка 404";
|
||||||
|
if (normalized.status === 409) return "Ошибка 409";
|
||||||
|
if (normalized.status === 422) return "Ошибка 422";
|
||||||
|
|
||||||
|
return "Техническая ошибка";
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { apiClient } from "./client";
|
import { apiDelete, apiGet, apiPost, apiPut, apiRequest } from "./client";
|
||||||
|
import { getApiErrorMessage, getApiFieldErrors, isRereadRequiredConflict } from "./errors";
|
||||||
|
import { queryKeys } from "./queryKeys";
|
||||||
|
import { rereadSchemeState } from "./reread";
|
||||||
import type {
|
import type {
|
||||||
ApiErrorPayload,
|
|
||||||
AuthMeResponse,
|
AuthMeResponse,
|
||||||
CreatePriceRuleRequest,
|
CreatePriceRuleRequest,
|
||||||
CreatePricingCategoryRequest,
|
CreatePricingCategoryRequest,
|
||||||
DisplaySvgMetaResponse,
|
DisplaySvgMetaResponse,
|
||||||
|
DraftComparePreviewResponse,
|
||||||
|
DraftStructureResponse,
|
||||||
|
DraftSummaryResponse,
|
||||||
|
DraftValidationResponse,
|
||||||
LifecycleActionResponse,
|
LifecycleActionResponse,
|
||||||
ManifestResponse,
|
ManifestResponse,
|
||||||
PriceRuleItem,
|
PriceRuleItem,
|
||||||
@@ -14,6 +20,7 @@ import type {
|
|||||||
SchemeAuditResponse,
|
SchemeAuditResponse,
|
||||||
SchemeCurrentVersionResponse,
|
SchemeCurrentVersionResponse,
|
||||||
SchemeDetailResponse,
|
SchemeDetailResponse,
|
||||||
|
SchemeEditorContextResponse,
|
||||||
SchemeGroupsResponse,
|
SchemeGroupsResponse,
|
||||||
SchemePricingResponse,
|
SchemePricingResponse,
|
||||||
SchemeSeatsResponse,
|
SchemeSeatsResponse,
|
||||||
@@ -26,42 +33,7 @@ import type {
|
|||||||
UploadSchemeResponse
|
UploadSchemeResponse
|
||||||
} from "../shared/types/api";
|
} from "../shared/types/api";
|
||||||
|
|
||||||
export function getApiErrorMessage(error: unknown): string {
|
export { getApiErrorMessage, getApiFieldErrors } from "./errors";
|
||||||
if (axios.isAxiosError<ApiErrorPayload>(error)) {
|
|
||||||
const status = error.response?.status;
|
|
||||||
const detail = typeof error.response?.data?.detail === "string" ? error.response.data.detail : undefined;
|
|
||||||
|
|
||||||
if (!error.response) return "Сетевой сбой или блокировка cross-origin запроса";
|
|
||||||
if (status === 400) return detail || "Некорректный запрос";
|
|
||||||
if (status === 401) return "Отсутствует API key";
|
|
||||||
if (status === 403) return "Неверный API key";
|
|
||||||
if (status === 404) return detail || "Ресурс не найден";
|
|
||||||
if (status === 409) return detail || "Конфликт состояния";
|
|
||||||
if (status === 413) return "Файл превышает допустимый лимит";
|
|
||||||
if (status === 422) return detail || "Ошибка валидации";
|
|
||||||
if (status && status >= 500) return "Backend вернул внутреннюю ошибку";
|
|
||||||
if (detail) return detail;
|
|
||||||
if (error.code === "ECONNABORTED") return "Таймаут запроса к backend";
|
|
||||||
return "Ошибка запроса к backend";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) return error.message;
|
|
||||||
return "Неизвестная ошибка";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getApiFieldErrors(error: unknown): Record<string, string> {
|
|
||||||
if (!axios.isAxiosError<ApiErrorPayload>(error)) return {};
|
|
||||||
const errors = error.response?.data?.errors;
|
|
||||||
if (!Array.isArray(errors)) return {};
|
|
||||||
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const item of errors) {
|
|
||||||
if (item && typeof item.field === "string" && typeof item.message === "string") {
|
|
||||||
result[item.field] = item.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItems<T>(payload: T[] | { items?: T[] } | null | undefined): T[] {
|
function getItems<T>(payload: T[] | { items?: T[] } | null | undefined): T[] {
|
||||||
if (Array.isArray(payload)) return payload;
|
if (Array.isArray(payload)) return payload;
|
||||||
@@ -69,83 +41,178 @@ function getItems<T>(payload: T[] | { items?: T[] } | null | undefined): T[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftReadParams(expectedSchemeVersionId: string | undefined) {
|
||||||
|
return {
|
||||||
|
params: {
|
||||||
|
expected_scheme_version_id: expectedSchemeVersionId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLifecycleReread(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
schemeId: string | undefined,
|
||||||
|
options?: {
|
||||||
|
includeEditorDraft?: boolean;
|
||||||
|
expectedSchemeVersionId?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!schemeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rereadSchemeState(queryClient, {
|
||||||
|
schemeId,
|
||||||
|
includeEditorDraft: options?.includeEditorDraft ?? true,
|
||||||
|
expectedSchemeVersionId: options?.expectedSchemeVersionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAuthMeQuery() {
|
export function useAuthMeQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["auth-me"],
|
queryKey: queryKeys.authMe,
|
||||||
queryFn: async () => (await apiClient.get<AuthMeResponse>("/api/v1/auth/me")).data,
|
queryFn: async () => apiGet<AuthMeResponse>("/api/v1/auth/me"),
|
||||||
retry: false
|
retry: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useManifestQuery() {
|
export function useManifestQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["manifest"],
|
queryKey: queryKeys.manifest,
|
||||||
queryFn: async () => (await apiClient.get<ManifestResponse>("/api/v1/manifest")).data,
|
queryFn: async () => apiGet<ManifestResponse>("/api/v1/manifest"),
|
||||||
retry: false
|
retry: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemesQuery() {
|
export function useSchemesQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["schemes"],
|
queryKey: queryKeys.schemes,
|
||||||
queryFn: async () => (await apiClient.get<SchemesListResponse>("/api/v1/schemes")).data
|
queryFn: async () => apiGet<SchemesListResponse>("/api/v1/schemes")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemeDetailQuery(schemeId: string | undefined) {
|
export function useSchemeDetailQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-detail", schemeId],
|
queryKey: queryKeys.schemeDetail(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => (await apiClient.get<SchemeDetailResponse>(`/api/v1/schemes/${schemeId}`)).data
|
queryFn: async () => apiGet<SchemeDetailResponse>(`/api/v1/schemes/${schemeId}`)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemeCurrentQuery(schemeId: string | undefined) {
|
export function useSchemeCurrentQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-current", schemeId],
|
queryKey: queryKeys.schemeCurrent(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => (await apiClient.get<SchemeCurrentVersionResponse>(`/api/v1/schemes/${schemeId}/current`)).data
|
queryFn: async () => apiGet<SchemeCurrentVersionResponse>(`/api/v1/schemes/${schemeId}/current`)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemeVersionsQuery(schemeId: string | undefined) {
|
export function useSchemeVersionsQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-versions", schemeId],
|
queryKey: queryKeys.schemeVersions(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => getItems((await apiClient.get<SchemeVersionsResponse>(`/api/v1/schemes/${schemeId}/versions`)).data)
|
queryFn: async () => getItems(await apiGet<SchemeVersionsResponse>(`/api/v1/schemes/${schemeId}/versions`))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSchemeEditorContextQuery(schemeId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.schemeEditorContext(schemeId),
|
||||||
|
enabled: Boolean(schemeId),
|
||||||
|
queryFn: async () => apiGet<SchemeEditorContextResponse>(`/api/v1/schemes/${schemeId}/editor/context`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftSummaryQuery(schemeId: string | undefined, expectedSchemeVersionId: string | undefined, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.schemeDraftSummary(schemeId, expectedSchemeVersionId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftSummaryResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/summary`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftStructureQuery(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.schemeDraftStructure(schemeId, expectedSchemeVersionId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftStructureResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/structure`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftValidationQuery(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftValidationResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/validation`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftComparePreviewQuery(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.schemeDraftComparePreview(schemeId, expectedSchemeVersionId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftComparePreviewResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/compare-preview`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemeSectorsQuery(schemeId: string | undefined) {
|
export function useSchemeSectorsQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-sectors", schemeId],
|
queryKey: queryKeys.schemeSectors(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => getItems((await apiClient.get<SchemeSectorsResponse>(`/api/v1/schemes/${schemeId}/current/sectors`)).data)
|
queryFn: async () => getItems(await apiGet<SchemeSectorsResponse>(`/api/v1/schemes/${schemeId}/current/sectors`))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemeGroupsQuery(schemeId: string | undefined) {
|
export function useSchemeGroupsQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-groups", schemeId],
|
queryKey: queryKeys.schemeGroups(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => getItems((await apiClient.get<SchemeGroupsResponse>(`/api/v1/schemes/${schemeId}/current/groups`)).data)
|
queryFn: async () => getItems(await apiGet<SchemeGroupsResponse>(`/api/v1/schemes/${schemeId}/current/groups`))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemeSeatsQuery(schemeId: string | undefined) {
|
export function useSchemeSeatsQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-seats", schemeId],
|
queryKey: queryKeys.schemeSeats(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => getItems((await apiClient.get<SchemeSeatsResponse>(`/api/v1/schemes/${schemeId}/current/seats`)).data)
|
queryFn: async () => getItems(await apiGet<SchemeSeatsResponse>(`/api/v1/schemes/${schemeId}/current/seats`))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemePricingQuery(schemeId: string | undefined) {
|
export function useSchemePricingQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-pricing", schemeId],
|
queryKey: queryKeys.schemePricing(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = (await apiClient.get<SchemePricingResponse>(`/api/v1/schemes/${schemeId}/pricing`)).data;
|
const data = await apiGet<SchemePricingResponse>(`/api/v1/schemes/${schemeId}/pricing`);
|
||||||
return { categories: data.categories ?? [], rules: data.rules ?? [] };
|
return { categories: data.categories ?? [], rules: data.rules ?? [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -153,63 +220,63 @@ export function useSchemePricingQuery(schemeId: string | undefined) {
|
|||||||
|
|
||||||
export function useSchemeAuditQuery(schemeId: string | undefined) {
|
export function useSchemeAuditQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-audit", schemeId],
|
queryKey: queryKeys.schemeAudit(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
queryFn: async () => getItems((await apiClient.get<SchemeAuditResponse>(`/api/v1/schemes/${schemeId}/audit`)).data)
|
queryFn: async () => getItems(await apiGet<SchemeAuditResponse>(`/api/v1/schemes/${schemeId}/audit`))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDisplaySvgMetaQuery(schemeId: string | undefined) {
|
export function useDisplaySvgMetaQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-display-meta", schemeId, "passthrough"],
|
queryKey: queryKeys.schemeDisplayMeta(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
apiGet<DisplaySvgMetaResponse>(`/api/v1/schemes/${schemeId}/current/svg/display/meta`, {
|
||||||
await apiClient.get<DisplaySvgMetaResponse>(`/api/v1/schemes/${schemeId}/current/svg/display/meta`, {
|
|
||||||
params: { mode: "passthrough" }
|
params: { mode: "passthrough" }
|
||||||
})
|
})
|
||||||
).data
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDisplaySvgQuery(schemeId: string | undefined) {
|
export function useDisplaySvgQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-display-svg", schemeId, "passthrough"],
|
queryKey: queryKeys.schemeDisplaySvg(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.get<string>(`/api/v1/schemes/${schemeId}/current/svg/display`, {
|
return apiRequest<string>({
|
||||||
|
url: `/api/v1/schemes/${schemeId}/current/svg/display`,
|
||||||
|
method: "get",
|
||||||
params: { mode: "passthrough" },
|
params: { mode: "passthrough" },
|
||||||
responseType: "text",
|
responseType: "text",
|
||||||
transformResponse: [(data) => data]
|
transformResponse: [(data) => data]
|
||||||
});
|
});
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLegacySvgQuery(schemeId: string | undefined) {
|
export function useLegacySvgQuery(schemeId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-legacy-svg", schemeId],
|
queryKey: queryKeys.schemeLegacySvg(schemeId),
|
||||||
enabled: Boolean(schemeId),
|
enabled: Boolean(schemeId),
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.get<string>(`/api/v1/schemes/${schemeId}/current/svg`, {
|
return apiRequest<string>({
|
||||||
|
url: `/api/v1/schemes/${schemeId}/current/svg`,
|
||||||
|
method: "get",
|
||||||
responseType: "text",
|
responseType: "text",
|
||||||
transformResponse: [(data) => data]
|
transformResponse: [(data) => data]
|
||||||
});
|
});
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTestSeatPreviewQuery(schemeId: string | undefined, seatId: string | undefined) {
|
export function useTestSeatPreviewQuery(schemeId: string | undefined, seatId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheme-test-seat", schemeId, seatId],
|
queryKey: queryKeys.schemeTestSeat(schemeId, seatId),
|
||||||
enabled: Boolean(schemeId && seatId),
|
enabled: Boolean(schemeId && seatId),
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () => (await apiClient.get<TestSeatPreviewResponse>(`/api/v1/schemes/${schemeId}/test/seats/${seatId}`)).data
|
queryFn: async () => apiGet<TestSeatPreviewResponse>(`/api/v1/schemes/${schemeId}/test/seats/${seatId}`)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,57 +287,76 @@ export function useUploadSchemeMutation() {
|
|||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
return (
|
const uploadResponse = await apiPost<UploadSchemeResponse>("/api/v1/schemes/upload", formData, {
|
||||||
await apiClient.post<UploadSchemeResponse>("/api/v1/schemes/upload", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" }
|
headers: { "Content-Type": "multipart/form-data" }
|
||||||
})
|
});
|
||||||
).data;
|
|
||||||
|
if (uploadResponse.scheme_id || !uploadResponse.upload_id) {
|
||||||
|
return uploadResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemes = await apiGet<SchemesListResponse>("/api/v1/schemes");
|
||||||
|
const resolvedScheme = schemes.items.find((item) => item.source_upload_id === uploadResponse.upload_id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...uploadResponse,
|
||||||
|
scheme_id: uploadResponse.scheme_id ?? resolvedScheme?.scheme_id
|
||||||
|
};
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["schemes"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemes });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refetchSchemeBundle(queryClient: ReturnType<typeof useQueryClient>, schemeId: string) {
|
export function useEnsureDraftMutation(schemeId: string | undefined) {
|
||||||
await Promise.all([
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["schemes"] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-detail", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-current", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-versions", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-sectors", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-groups", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-seats", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-display-meta", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-display-svg", schemeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["scheme-legacy-svg", schemeId] })
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateNextVersionMutation(schemeId: string | undefined) {
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (params?: { expectedCurrentSchemeVersionId?: string | null }) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/versions`)).data;
|
return apiPost<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/draft/ensure`, undefined, {
|
||||||
|
params: params?.expectedCurrentSchemeVersionId
|
||||||
|
? { expected_current_scheme_version_id: params.expectedCurrentSchemeVersionId }
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (data) => {
|
||||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
await handleLifecycleReread(queryClient, schemeId, {
|
||||||
|
includeEditorDraft: true,
|
||||||
|
expectedSchemeVersionId: data.scheme_version_id ?? null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleLifecycleReread(queryClient, schemeId, { includeEditorDraft: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePublishSchemeMutation(schemeId: string | undefined) {
|
export function usePublishSchemeMutation(schemeId: string | undefined) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (params?: { expectedSchemeVersionId?: string | null }) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/publish`)).data;
|
return apiPost<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/publish`, undefined, {
|
||||||
|
params: params?.expectedSchemeVersionId
|
||||||
|
? { expected_scheme_version_id: params.expectedSchemeVersionId }
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
await handleLifecycleReread(queryClient, schemeId, { includeEditorDraft: true });
|
||||||
|
},
|
||||||
|
onError: async (error, variables) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleLifecycleReread(queryClient, schemeId, {
|
||||||
|
includeEditorDraft: true,
|
||||||
|
expectedSchemeVersionId: variables?.expectedSchemeVersionId ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -280,10 +366,15 @@ export function useUnpublishSchemeMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/unpublish`)).data;
|
return apiPost<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/unpublish`);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
await handleLifecycleReread(queryClient, schemeId, { includeEditorDraft: true });
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleLifecycleReread(queryClient, schemeId, { includeEditorDraft: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,14 +384,17 @@ export function useRollbackSchemeMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (targetVersionNumber: number) => {
|
mutationFn: async (targetVersionNumber: number) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (
|
return apiPost<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/rollback`, {
|
||||||
await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/rollback`, {
|
|
||||||
target_version_number: targetVersionNumber
|
target_version_number: targetVersionNumber
|
||||||
})
|
});
|
||||||
).data;
|
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
await handleLifecycleReread(queryClient, schemeId, { includeEditorDraft: true });
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleLifecycleReread(queryClient, schemeId, { includeEditorDraft: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,12 +404,12 @@ export function useCreatePricingCategoryMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: CreatePricingCategoryRequest) => {
|
mutationFn: async (payload: CreatePricingCategoryRequest) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (await apiClient.post<PricingCategoryItem>(`/api/v1/schemes/${schemeId}/pricing/categories`, payload)).data;
|
return apiPost<PricingCategoryItem>(`/api/v1/schemes/${schemeId}/pricing/categories`, payload);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -326,17 +420,15 @@ export function useUpdatePricingCategoryMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (params: { pricingCategoryId: string; payload: UpdatePricingCategoryRequest }) => {
|
mutationFn: async (params: { pricingCategoryId: string; payload: UpdatePricingCategoryRequest }) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (
|
return apiPut<PricingCategoryItem>(
|
||||||
await apiClient.put<PricingCategoryItem>(
|
|
||||||
`/api/v1/schemes/${schemeId}/pricing/categories/${params.pricingCategoryId}`,
|
`/api/v1/schemes/${schemeId}/pricing/categories/${params.pricingCategoryId}`,
|
||||||
params.payload
|
params.payload
|
||||||
)
|
);
|
||||||
).data;
|
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -347,13 +439,13 @@ export function useDeletePricingCategoryMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (pricingCategoryId: string) => {
|
mutationFn: async (pricingCategoryId: string) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
await apiClient.delete(`/api/v1/schemes/${schemeId}/pricing/categories/${pricingCategoryId}`);
|
await apiDelete<unknown>(`/api/v1/schemes/${schemeId}/pricing/categories/${pricingCategoryId}`);
|
||||||
return { pricingCategoryId };
|
return { pricingCategoryId };
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -364,12 +456,12 @@ export function useCreatePriceRuleMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: CreatePriceRuleRequest) => {
|
mutationFn: async (payload: CreatePriceRuleRequest) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (await apiClient.post<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules`, payload)).data;
|
return apiPost<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules`, payload);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -380,14 +472,12 @@ export function useUpdatePriceRuleMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (params: { priceRuleId: string; payload: UpdatePriceRuleRequest }) => {
|
mutationFn: async (params: { priceRuleId: string; payload: UpdatePriceRuleRequest }) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
return (
|
return apiPut<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules/${params.priceRuleId}`, params.payload);
|
||||||
await apiClient.put<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules/${params.priceRuleId}`, params.payload)
|
|
||||||
).data;
|
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -398,13 +488,13 @@ export function useDeletePriceRuleMutation(schemeId: string | undefined) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (priceRuleId: string) => {
|
mutationFn: async (priceRuleId: string) => {
|
||||||
if (!schemeId) throw new Error("schemeId is required");
|
if (!schemeId) throw new Error("schemeId is required");
|
||||||
await apiClient.delete(`/api/v1/schemes/${schemeId}/pricing/rules/${priceRuleId}`);
|
await apiDelete<unknown>(`/api/v1/schemes/${schemeId}/pricing/rules/${priceRuleId}`);
|
||||||
return { priceRuleId };
|
return { priceRuleId };
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
26
src/api/queryKeys.ts
Normal file
26
src/api/queryKeys.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export const queryKeys = {
|
||||||
|
authMe: ["auth-me"] as const,
|
||||||
|
manifest: ["manifest"] as const,
|
||||||
|
schemes: ["schemes"] as const,
|
||||||
|
schemeDetail: (schemeId: string | undefined) => ["scheme-detail", schemeId] as const,
|
||||||
|
schemeCurrent: (schemeId: string | undefined) => ["scheme-current", schemeId] as const,
|
||||||
|
schemeVersions: (schemeId: string | undefined) => ["scheme-versions", schemeId] as const,
|
||||||
|
schemeEditorContext: (schemeId: string | undefined) => ["scheme-editor-context", schemeId] as const,
|
||||||
|
schemeDraftSummary: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
|
["scheme-draft-summary", schemeId, expectedVersionId] as const,
|
||||||
|
schemeDraftStructure: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
|
["scheme-draft-structure", schemeId, expectedVersionId] as const,
|
||||||
|
schemeDraftValidation: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
|
["scheme-draft-validation", schemeId, expectedVersionId] as const,
|
||||||
|
schemeDraftComparePreview: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
|
["scheme-draft-compare-preview", schemeId, expectedVersionId] as const,
|
||||||
|
schemeSectors: (schemeId: string | undefined) => ["scheme-sectors", schemeId] as const,
|
||||||
|
schemeGroups: (schemeId: string | undefined) => ["scheme-groups", schemeId] as const,
|
||||||
|
schemeSeats: (schemeId: string | undefined) => ["scheme-seats", schemeId] as const,
|
||||||
|
schemePricing: (schemeId: string | undefined) => ["scheme-pricing", schemeId] as const,
|
||||||
|
schemeAudit: (schemeId: string | undefined) => ["scheme-audit", schemeId] as const,
|
||||||
|
schemeDisplayMeta: (schemeId: string | undefined) => ["scheme-display-meta", schemeId, "passthrough"] as const,
|
||||||
|
schemeDisplaySvg: (schemeId: string | undefined) => ["scheme-display-svg", schemeId, "passthrough"] as const,
|
||||||
|
schemeLegacySvg: (schemeId: string | undefined) => ["scheme-legacy-svg", schemeId] as const,
|
||||||
|
schemeTestSeat: (schemeId: string | undefined, seatId: string | undefined) => ["scheme-test-seat", schemeId, seatId] as const
|
||||||
|
};
|
||||||
47
src/api/reread.ts
Normal file
47
src/api/reread.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { queryKeys } from "./queryKeys";
|
||||||
|
|
||||||
|
type RereadSchemeStateOptions = {
|
||||||
|
schemeId: string;
|
||||||
|
includeEditorDraft?: boolean;
|
||||||
|
expectedSchemeVersionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function rereadSchemeState(queryClient: QueryClient, options: RereadSchemeStateOptions) {
|
||||||
|
const { schemeId, includeEditorDraft = false, expectedSchemeVersionId } = options;
|
||||||
|
|
||||||
|
const invalidations = [
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemes }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeDetail(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeCurrent(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeVersions(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeEditorContext(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeSectors(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeGroups(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeSeats(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeDisplayMeta(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeDisplaySvg(schemeId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.schemeLegacySvg(schemeId) })
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeEditorDraft) {
|
||||||
|
invalidations.push(
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.schemeDraftSummary(schemeId, expectedSchemeVersionId ?? undefined)
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.schemeDraftStructure(schemeId, expectedSchemeVersionId ?? undefined)
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId ?? undefined)
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.schemeDraftComparePreview(schemeId, expectedSchemeVersionId ?? undefined)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(invalidations);
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { AppRouter } from "./router";
|
import { AppRouter } from "./router";
|
||||||
|
import { AuthProvider } from "../shared/auth/AuthProvider";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <AppRouter />;
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRouter />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||||
import { useAuthMeQuery, useManifestQuery, getApiErrorMessage } from "../api/queries";
|
|
||||||
import { getAppConfig } from "../shared/config/env";
|
import { getAppConfig } from "../shared/config/env";
|
||||||
import { ApiErrorView } from "../shared/ui/ApiErrorView";
|
import { ApiErrorView } from "../shared/ui/ApiErrorView";
|
||||||
import { localizeRole } from "../shared/lib/formatters";
|
import { localizeRole } from "../shared/lib/formatters";
|
||||||
|
import { useAuthState } from "../shared/auth/AuthProvider";
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const config = getAppConfig();
|
const config = getAppConfig();
|
||||||
const authQuery = useAuthMeQuery();
|
const authState = useAuthState();
|
||||||
const manifestQuery = useManifestQuery();
|
|
||||||
|
|
||||||
const authError = authQuery.isError ? getApiErrorMessage(authQuery.error) : null;
|
|
||||||
const manifest = manifestQuery.data;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="logo-block">
|
<div className="logo-block">
|
||||||
<div className="logo-title">{config.appTitle}</div>
|
<div className="logo-title">{config.appTitle}</div>
|
||||||
<div className="logo-subtitle">Админка / тестовый интерфейс</div>
|
<div className="logo-subtitle">Product integration shell</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="menu">
|
<nav className="menu">
|
||||||
@@ -32,14 +28,16 @@ export function AppLayout() {
|
|||||||
<div className="sidebar-box">
|
<div className="sidebar-box">
|
||||||
<div className="sidebar-box-title">Подключение</div>
|
<div className="sidebar-box-title">Подключение</div>
|
||||||
<div className="kv"><span>API</span><span>{config.apiBaseUrl}</span></div>
|
<div className="kv"><span>API</span><span>{config.apiBaseUrl}</span></div>
|
||||||
<div className="kv"><span>Авторизация</span><span>{authQuery.isLoading ? "..." : localizeRole(authQuery.data?.role) ?? "ошибка"}</span></div>
|
<div className="kv"><span>Роль</span><span>{authState.isLoading ? "..." : localizeRole(authState.role) ?? "ошибка"}</span></div>
|
||||||
<div className="kv"><span>Заголовок</span><span>{authQuery.data?.auth_header ?? manifest?.auth_header_name ?? "X-API-Key"}</span></div>
|
<div className="kv"><span>Product UI</span><span>{authState.canAccessProduct ? "доступен" : "нет"}</span></div>
|
||||||
|
<div className="kv"><span>Admin capability</span><span>{authState.canAccessAdmin ? "есть" : "скрыта"}</span></div>
|
||||||
|
<div className="kv"><span>Заголовок</span><span>{authState.authHeaderName}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-box">
|
<div className="sidebar-box">
|
||||||
<div className="sidebar-box-title">Лимиты</div>
|
<div className="sidebar-box-title">Лимиты</div>
|
||||||
<div className="kv"><span>Макс. размер SVG</span><span>{manifest?.svg_limits?.max_file_size_bytes ?? "n/a"}</span></div>
|
<div className="kv"><span>Макс. размер SVG</span><span>{authState.manifest?.svg_limits?.max_file_size_bytes ?? "n/a"}</span></div>
|
||||||
<div className="kv"><span>Макс. элементов SVG</span><span>{manifest?.svg_limits?.max_elements ?? "n/a"}</span></div>
|
<div className="kv"><span>Макс. элементов SVG</span><span>{authState.manifest?.svg_limits?.max_elements ?? "n/a"}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -47,7 +45,7 @@ export function AppLayout() {
|
|||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">Схема зала</div>
|
<div className="page-title">Схема зала</div>
|
||||||
<div className="page-subtitle">frontend v1</div>
|
<div className="page-subtitle">frontend integration shell</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="top-actions">
|
<div className="top-actions">
|
||||||
@@ -56,11 +54,11 @@ export function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{authError ? (
|
{authState.authError ? (
|
||||||
<main className="main">
|
<main className="main">
|
||||||
<ApiErrorView
|
<ApiErrorView
|
||||||
title="Ошибка доступа"
|
title="Ошибка доступа"
|
||||||
message={`${authError}. Проверь VITE_API_BASE_URL и VITE_API_KEY.`}
|
message={`${authState.authError.message}. Проверь VITE_API_BASE_URL и VITE_API_KEY.`}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -229,6 +229,30 @@ select {
|
|||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
border: 1px solid #dbe3f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block-success {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block-warning {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block-info {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||||
@@ -428,6 +452,12 @@ select {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}\n
|
}\n
|
||||||
|
|
||||||
|
.debug-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-grid {
|
.viewer-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
||||||
|
|||||||
441
src/features/schemes/SchemeEditorTab.tsx
Normal file
441
src/features/schemes/SchemeEditorTab.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getApiErrorMessage,
|
||||||
|
useDraftComparePreviewQuery,
|
||||||
|
useDraftStructureQuery,
|
||||||
|
useDraftSummaryQuery,
|
||||||
|
useDraftValidationQuery,
|
||||||
|
useEnsureDraftMutation,
|
||||||
|
useSchemeCurrentQuery,
|
||||||
|
useSchemeEditorContextQuery
|
||||||
|
} from "../../api/queries";
|
||||||
|
import { normalizeApiError, isRereadRequiredConflict } from "../../api/errors";
|
||||||
|
import { rereadSchemeState } from "../../api/reread";
|
||||||
|
import { useAuthState } from "../../shared/auth/AuthProvider";
|
||||||
|
import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters";
|
||||||
|
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||||
|
import { StatCard } from "../../shared/ui/StatCard";
|
||||||
|
import type {
|
||||||
|
DraftComparePreviewResponse,
|
||||||
|
DraftStructureGroupItem,
|
||||||
|
DraftStructureResponse,
|
||||||
|
DraftStructureSeatItem,
|
||||||
|
DraftStructureSectorItem,
|
||||||
|
DraftValidationResponse
|
||||||
|
} from "../../shared/types/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
schemeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function valueText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolText(value: boolean | null | undefined): string {
|
||||||
|
if (value === true) return "Да";
|
||||||
|
if (value === false) return "Нет";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJson(value: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seatBusinessId(item: DraftStructureSeatItem): string {
|
||||||
|
return item.seat_id || item.element_id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectorBusinessId(item: DraftStructureSectorItem): string {
|
||||||
|
return item.sector_id || item.element_id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBusinessId(item: DraftStructureGroupItem): string {
|
||||||
|
return item.group_id || item.element_id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function issuesList(payload: DraftValidationResponse | undefined) {
|
||||||
|
return {
|
||||||
|
issues: Array.isArray(payload?.issues) ? payload.issues : [],
|
||||||
|
warnings: Array.isArray(payload?.warnings) ? payload.warnings : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBaselineMissing(payload: DraftComparePreviewResponse | undefined): boolean {
|
||||||
|
if (!payload) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBaselineMarker =
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, "baseline_scheme_version_id") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, "baseline_version_number");
|
||||||
|
|
||||||
|
return hasBaselineMarker && payload.baseline_scheme_version_id == null && payload.baseline_version_number == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function structureLists(payload: DraftStructureResponse | undefined) {
|
||||||
|
return {
|
||||||
|
sectors: Array.isArray(payload?.sectors) ? payload.sectors : [],
|
||||||
|
groups: Array.isArray(payload?.groups) ? payload.groups : [],
|
||||||
|
seats: Array.isArray(payload?.seats) ? payload.seats : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemeEditorTab({ schemeId }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const authState = useAuthState();
|
||||||
|
|
||||||
|
const currentQuery = useSchemeCurrentQuery(schemeId);
|
||||||
|
const contextQuery = useSchemeEditorContextQuery(schemeId);
|
||||||
|
const ensureDraftMutation = useEnsureDraftMutation(schemeId);
|
||||||
|
|
||||||
|
const currentVersionId = currentQuery.data?.scheme_version_id;
|
||||||
|
const expectedSchemeVersionId = contextQuery.data?.current_scheme_version_id ?? currentVersionId;
|
||||||
|
const shouldEnsureDraft =
|
||||||
|
contextQuery.data?.recommended_action === "create_draft" &&
|
||||||
|
contextQuery.data?.create_draft_available === true &&
|
||||||
|
Boolean(currentVersionId);
|
||||||
|
const editorReady = contextQuery.data?.current_is_draft === true && Boolean(expectedSchemeVersionId);
|
||||||
|
|
||||||
|
const summaryQuery = useDraftSummaryQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
|
const structureQuery = useDraftStructureQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
|
const validationQuery = useDraftValidationQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
|
const compareQuery = useDraftComparePreviewQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
|
|
||||||
|
const [autoEnsureAttemptedFor, setAutoEnsureAttemptedFor] = useState<string | null>(null);
|
||||||
|
const [statusNote, setStatusNote] = useState<string | null>(null);
|
||||||
|
const [lastConflictSignature, setLastConflictSignature] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldEnsureDraft || !currentVersionId || ensureDraftMutation.isPending || autoEnsureAttemptedFor === currentVersionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoEnsureAttemptedFor(currentVersionId);
|
||||||
|
setStatusNote("Текущая версия опубликована. Выполняется `draft/ensure` перед чтением editor shell.");
|
||||||
|
ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId });
|
||||||
|
}, [autoEnsureAttemptedFor, currentVersionId, ensureDraftMutation, ensureDraftMutation.isPending, shouldEnsureDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ensureDraftMutation.data?.created) {
|
||||||
|
setStatusNote("Backend создал новый draft. Выполняется controlled reread current/context и draft read models.");
|
||||||
|
} else if (ensureDraftMutation.data && ensureDraftMutation.data.created === false) {
|
||||||
|
setStatusNote("Current draft уже был активен. Editor shell работает в нём.");
|
||||||
|
}
|
||||||
|
}, [ensureDraftMutation.data]);
|
||||||
|
|
||||||
|
const draftConflictError = useMemo(() => {
|
||||||
|
const candidates = [
|
||||||
|
summaryQuery.error,
|
||||||
|
structureQuery.error,
|
||||||
|
validationQuery.error,
|
||||||
|
compareQuery.error,
|
||||||
|
ensureDraftMutation.error
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidates.find((item) => isRereadRequiredConflict(item)) ?? null;
|
||||||
|
}, [
|
||||||
|
compareQuery.error,
|
||||||
|
ensureDraftMutation.error,
|
||||||
|
structureQuery.error,
|
||||||
|
summaryQuery.error,
|
||||||
|
validationQuery.error
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draftConflictError || !expectedSchemeVersionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeApiError(draftConflictError);
|
||||||
|
const signature = `${normalized.code ?? "conflict"}:${expectedSchemeVersionId}`;
|
||||||
|
|
||||||
|
if (signature === lastConflictSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastConflictSignature(signature);
|
||||||
|
setStatusNote("Backend вернул stale conflict. Editor shell перечитывает current/context и draft read models.");
|
||||||
|
|
||||||
|
void rereadSchemeState(queryClient, {
|
||||||
|
schemeId,
|
||||||
|
includeEditorDraft: true,
|
||||||
|
expectedSchemeVersionId
|
||||||
|
});
|
||||||
|
}, [draftConflictError, expectedSchemeVersionId, lastConflictSignature, queryClient, schemeId]);
|
||||||
|
|
||||||
|
if (currentQuery.isLoading || contextQuery.isLoading) {
|
||||||
|
return <div className="panel">Загрузка editor flow...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentQuery.isError) {
|
||||||
|
return <ApiErrorView title="Ошибка загрузки current" message={getApiErrorMessage(currentQuery.error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextQuery.isError) {
|
||||||
|
return <ApiErrorView title="Ошибка загрузки editor context" message={getApiErrorMessage(contextQuery.error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const structure = structureLists(structureQuery.data);
|
||||||
|
const validationState = issuesList(validationQuery.data);
|
||||||
|
const compareWithoutBaseline = compareBaselineMissing(compareQuery.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<div className="scheme-card-header">
|
||||||
|
<div>
|
||||||
|
<h2>Редактор</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Editor shell читает только backend read models: `draft/summary`, `draft/structure`, `draft/validation`,
|
||||||
|
`draft/compare-preview`.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="badge">{editorReady ? "Draft shell active" : "Entry in progress"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<StatCard label="Current version id" value={currentVersionId ?? "—"} />
|
||||||
|
<StatCard label="Expected draft id" value={expectedSchemeVersionId ?? "—"} />
|
||||||
|
<StatCard label="current_is_draft" value={boolText(contextQuery.data?.current_is_draft)} />
|
||||||
|
<StatCard label="recommended_action" value={localizeRecommendedAction(contextQuery.data?.recommended_action)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusNote ? (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="state-block state-block-info">
|
||||||
|
<strong>Controlled flow status</strong>
|
||||||
|
<div className="muted">{statusNote}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!editorReady && shouldEnsureDraft ? (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="state-block state-block-warning">
|
||||||
|
<strong>Требуется подготовка draft.</strong>
|
||||||
|
<div className="muted">
|
||||||
|
Пока `draft/ensure` не завершится и `current`/`editor/context` не будут перечитаны, editor shell не читает draft models.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!editorReady && !shouldEnsureDraft ? (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="state-block state-block-warning">
|
||||||
|
<strong>Editor shell пока недоступен.</strong>
|
||||||
|
<div className="muted">
|
||||||
|
Проверь `editor/context`: current_is_draft={boolText(contextQuery.data?.current_is_draft)}, create_draft_available=
|
||||||
|
{boolText(contextQuery.data?.create_draft_available)}, recommended_action=
|
||||||
|
{localizeRecommendedAction(contextQuery.data?.recommended_action)}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ensureDraftMutation.isError ? (
|
||||||
|
<ApiErrorView title="Ошибка подготовки draft" message={getApiErrorMessage(ensureDraftMutation.error)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && (summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? (
|
||||||
|
<div className="panel">Загрузка draft read models...</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && summaryQuery.isError ? (
|
||||||
|
<ApiErrorView title="Ошибка чтения draft summary" message={getApiErrorMessage(summaryQuery.error)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && structureQuery.isError ? (
|
||||||
|
<ApiErrorView title="Ошибка чтения draft structure" message={getApiErrorMessage(structureQuery.error)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && validationQuery.isError ? (
|
||||||
|
<ApiErrorView title="Ошибка чтения draft validation" message={getApiErrorMessage(validationQuery.error)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && compareQuery.isError ? (
|
||||||
|
<ApiErrorView title="Ошибка чтения draft compare-preview" message={getApiErrorMessage(compareQuery.error)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && summaryQuery.data ? (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Summary</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} />
|
||||||
|
<StatCard label="version number" value={summaryQuery.data.version_number ?? "—"} />
|
||||||
|
<StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} />
|
||||||
|
<StatCard label="publish readiness" value={summaryQuery.data.publish_readiness ? "есть" : "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid" style={{ marginTop: 10 }}>
|
||||||
|
<StatCard label="total seats" value={summaryQuery.data.total_seats ?? "—"} />
|
||||||
|
<StatCard label="total groups" value={summaryQuery.data.total_groups ?? "—"} />
|
||||||
|
<StatCard label="total sectors" value={summaryQuery.data.total_sectors ?? "—"} />
|
||||||
|
<StatCard label="readiness summary" value={summaryQuery.data.readiness_summary ? "есть" : "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="code-box" style={{ marginTop: 12 }}>
|
||||||
|
{prettyJson({
|
||||||
|
readiness_summary: summaryQuery.data.readiness_summary ?? null,
|
||||||
|
aggregate_counts: summaryQuery.data.aggregate_counts ?? null,
|
||||||
|
validation_summary: summaryQuery.data.validation_summary ?? null,
|
||||||
|
structure_diff_summary: summaryQuery.data.structure_diff_summary ?? null,
|
||||||
|
publish_readiness: summaryQuery.data.publish_readiness ?? null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && structureQuery.data ? (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Structure</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<StatCard label="sectors" value={structure.sectors.length} />
|
||||||
|
<StatCard label="groups" value={structure.groups.length} />
|
||||||
|
<StatCard label="seats" value={structure.seats.length} />
|
||||||
|
<StatCard label="scheme_version_id" value={structureQuery.data.scheme_version_id ?? "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{ marginTop: 12 }}>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entity</th>
|
||||||
|
<th>Business ID</th>
|
||||||
|
<th>element_id</th>
|
||||||
|
<th>Relations</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{structure.sectors.map((item, index) => (
|
||||||
|
<tr key={`sector-${item.sector_id ?? item.element_id ?? index}`}>
|
||||||
|
<td>sector</td>
|
||||||
|
<td>{sectorBusinessId(item)}</td>
|
||||||
|
<td>{valueText(item.element_id)}</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>{valueText(item.name)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{structure.groups.map((item, index) => (
|
||||||
|
<tr key={`group-${item.group_id ?? item.element_id ?? index}`}>
|
||||||
|
<td>group</td>
|
||||||
|
<td>{groupBusinessId(item)}</td>
|
||||||
|
<td>{valueText(item.element_id)}</td>
|
||||||
|
<td>sector_id={valueText(item.sector_id)}</td>
|
||||||
|
<td>{valueText(item.name)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{structure.seats.map((item, index) => (
|
||||||
|
<tr key={`seat-${item.seat_id ?? item.element_id ?? index}`}>
|
||||||
|
<td>seat</td>
|
||||||
|
<td>{seatBusinessId(item)}</td>
|
||||||
|
<td>{valueText(item.element_id)}</td>
|
||||||
|
<td>
|
||||||
|
sector_id={valueText(item.sector_id)} / group_id={valueText(item.group_id)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
row={valueText(item.row_label)} / seat={valueText(item.seat_number)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{structure.sectors.length === 0 && structure.groups.length === 0 && structure.seats.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>Draft structure returned empty lists.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && validationQuery.data ? (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Validation</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<StatCard label="is_publishable" value={boolText(validationQuery.data.is_publishable)} />
|
||||||
|
<StatCard label="issues" value={validationState.issues.length} />
|
||||||
|
<StatCard label="warnings" value={validationState.warnings.length} />
|
||||||
|
<StatCard label="indicators" value={validationQuery.data.indicators ? "есть" : "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationState.issues.length === 0 && validationState.warnings.length === 0 ? (
|
||||||
|
<div className="state-block state-block-success" style={{ marginTop: 12 }}>
|
||||||
|
<strong>Validation issues не найдены.</strong>
|
||||||
|
<div className="muted">Пустой набор warnings/issues не считается ошибкой интеграции.</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{validationState.issues.length > 0 ? (
|
||||||
|
<div className="code-box" style={{ marginTop: 12 }}>
|
||||||
|
{prettyJson(validationState.issues)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{validationState.warnings.length > 0 ? (
|
||||||
|
<div className="code-box" style={{ marginTop: 12 }}>
|
||||||
|
{prettyJson(validationState.warnings)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{validationQuery.data.indicators ? (
|
||||||
|
<div className="code-box" style={{ marginTop: 12 }}>
|
||||||
|
{prettyJson(validationQuery.data.indicators)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editorReady && compareQuery.data ? (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Compare preview</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<StatCard label="baseline version" value={compareQuery.data.baseline_version_number ?? "—"} />
|
||||||
|
<StatCard label="baseline id" value={compareQuery.data.baseline_scheme_version_id ?? "—"} />
|
||||||
|
<StatCard label="has_structure_changes" value={boolText(compareQuery.data.has_structure_changes)} />
|
||||||
|
<StatCard label="diff summary" value={compareQuery.data.diff_summary ? "есть" : "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{compareWithoutBaseline ? (
|
||||||
|
<div className="state-block state-block-info" style={{ marginTop: 12 }}>
|
||||||
|
<strong>Baseline отсутствует.</strong>
|
||||||
|
<div className="muted">Для fresh scheme compare-preview может вернуться без baseline, это controlled empty state, а не ошибка.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="code-box" style={{ marginTop: 12 }}>
|
||||||
|
{prettyJson(compareQuery.data)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Debug visibility</h3>
|
||||||
|
<div className="debug-grid">
|
||||||
|
<div><strong>scheme_id:</strong> <span className="mono-cell">{schemeId}</span></div>
|
||||||
|
<div><strong>scheme_version_id:</strong> <span className="mono-cell">{valueText(expectedSchemeVersionId)}</span></div>
|
||||||
|
<div><strong>current role:</strong> {localizeRole(authState.role)}</div>
|
||||||
|
<div><strong>current scheme status:</strong> {localizeStatus(contextQuery.data?.scheme_status)}</div>
|
||||||
|
<div><strong>current version status:</strong> {localizeStatus(contextQuery.data?.scheme_version_status)}</div>
|
||||||
|
<div><strong>current_is_draft:</strong> {boolText(contextQuery.data?.current_is_draft)}</div>
|
||||||
|
<div><strong>recommended_action:</strong> {localizeRecommendedAction(contextQuery.data?.recommended_action)}</div>
|
||||||
|
<div><strong>last conflict code:</strong> {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,38 @@
|
|||||||
import { getApiErrorMessage, useCreateNextVersionMutation, usePublishSchemeMutation, useRollbackSchemeMutation, useSchemeCurrentQuery, useSchemeDetailQuery, useSchemeVersionsQuery, useUnpublishSchemeMutation } from "../../api/queries";
|
import { getApiErrorMessage, useEnsureDraftMutation, usePublishSchemeMutation, useRollbackSchemeMutation, useSchemeCurrentQuery, useSchemeDetailQuery, useSchemeEditorContextQuery, useSchemeVersionsQuery, useUnpublishSchemeMutation } from "../../api/queries";
|
||||||
import { localizeStatus } from "../../shared/lib/formatters";
|
import { isRereadRequiredConflict, normalizeApiError } from "../../api/errors";
|
||||||
|
import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters";
|
||||||
|
import { useAuthState } from "../../shared/auth/AuthProvider";
|
||||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||||
import { StatCard } from "../../shared/ui/StatCard";
|
import { StatCard } from "../../shared/ui/StatCard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schemeId: string;
|
schemeId: string;
|
||||||
|
onOpenEditor: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SchemeOverviewTab({ schemeId }: Props) {
|
function boolText(value: boolean | null | undefined): string {
|
||||||
|
if (value === true) return "Да";
|
||||||
|
if (value === false) return "Нет";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemeOverviewTab({ schemeId, onOpenEditor }: Props) {
|
||||||
|
const authState = useAuthState();
|
||||||
const detailQuery = useSchemeDetailQuery(schemeId);
|
const detailQuery = useSchemeDetailQuery(schemeId);
|
||||||
const currentQuery = useSchemeCurrentQuery(schemeId);
|
const currentQuery = useSchemeCurrentQuery(schemeId);
|
||||||
|
const contextQuery = useSchemeEditorContextQuery(schemeId);
|
||||||
const versionsQuery = useSchemeVersionsQuery(schemeId);
|
const versionsQuery = useSchemeVersionsQuery(schemeId);
|
||||||
|
|
||||||
|
const ensureDraftMutation = useEnsureDraftMutation(schemeId);
|
||||||
const publishMutation = usePublishSchemeMutation(schemeId);
|
const publishMutation = usePublishSchemeMutation(schemeId);
|
||||||
const unpublishMutation = useUnpublishSchemeMutation(schemeId);
|
const unpublishMutation = useUnpublishSchemeMutation(schemeId);
|
||||||
const createNextVersionMutation = useCreateNextVersionMutation(schemeId);
|
|
||||||
const rollbackMutation = useRollbackSchemeMutation(schemeId);
|
const rollbackMutation = useRollbackSchemeMutation(schemeId);
|
||||||
|
|
||||||
const versions = versionsQuery.data ?? [];
|
const versions = versionsQuery.data ?? [];
|
||||||
@@ -24,12 +42,12 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
.sort((a, b) => b - a)[0];
|
.sort((a, b) => b - a)[0];
|
||||||
|
|
||||||
const isBusy =
|
const isBusy =
|
||||||
|
ensureDraftMutation.isPending ||
|
||||||
publishMutation.isPending ||
|
publishMutation.isPending ||
|
||||||
unpublishMutation.isPending ||
|
unpublishMutation.isPending ||
|
||||||
createNextVersionMutation.isPending ||
|
|
||||||
rollbackMutation.isPending;
|
rollbackMutation.isPending;
|
||||||
|
|
||||||
if (detailQuery.isLoading || currentQuery.isLoading || versionsQuery.isLoading) {
|
if (detailQuery.isLoading || currentQuery.isLoading || contextQuery.isLoading || versionsQuery.isLoading) {
|
||||||
return <div className="panel">Загрузка карточки схемы...</div>;
|
return <div className="panel">Загрузка карточки схемы...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,12 +59,31 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
return <ApiErrorView title="Ошибка загрузки текущей версии" message={getApiErrorMessage(currentQuery.error)} />;
|
return <ApiErrorView title="Ошибка загрузки текущей версии" message={getApiErrorMessage(currentQuery.error)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contextQuery.isError) {
|
||||||
|
return <ApiErrorView title="Ошибка загрузки editor context" message={getApiErrorMessage(contextQuery.error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (versionsQuery.isError) {
|
if (versionsQuery.isError) {
|
||||||
return <ApiErrorView title="Ошибка загрузки версий" message={getApiErrorMessage(versionsQuery.error)} />;
|
return <ApiErrorView title="Ошибка загрузки версий" message={getApiErrorMessage(versionsQuery.error)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheme = detailQuery.data;
|
const scheme = detailQuery.data;
|
||||||
const current = currentQuery.data;
|
const current = currentQuery.data;
|
||||||
|
const context = contextQuery.data;
|
||||||
|
const currentVersionId = current?.scheme_version_id;
|
||||||
|
|
||||||
|
const lifecycleError =
|
||||||
|
ensureDraftMutation.error ?? publishMutation.error ?? unpublishMutation.error ?? rollbackMutation.error;
|
||||||
|
|
||||||
|
const rereadRequired =
|
||||||
|
isRereadRequiredConflict(detailQuery.error) ||
|
||||||
|
isRereadRequiredConflict(currentQuery.error) ||
|
||||||
|
isRereadRequiredConflict(contextQuery.error) ||
|
||||||
|
isRereadRequiredConflict(lifecycleError);
|
||||||
|
|
||||||
|
const recommendedAction = context?.recommended_action ?? null;
|
||||||
|
const shouldEnsureDraft =
|
||||||
|
context?.recommended_action === "create_draft" && context?.create_draft_available === true && Boolean(currentVersionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
@@ -83,6 +120,67 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Editor entry</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<StatCard label="current_is_draft" value={boolText(context?.current_is_draft)} />
|
||||||
|
<StatCard label="create_draft_available" value={boolText(context?.create_draft_available)} />
|
||||||
|
<StatCard label="recommended_action" value={localizeRecommendedAction(recommendedAction)} />
|
||||||
|
<StatCard label="Context version id" value={context?.current_scheme_version_id ?? "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar" style={{ marginTop: 12 }}>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={isBusy} onClick={onOpenEditor}>
|
||||||
|
Войти в редактор
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={isBusy || !shouldEnsureDraft}
|
||||||
|
onClick={() => ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId })}
|
||||||
|
>
|
||||||
|
{ensureDraftMutation.isPending ? "Подготовка draft..." : "Подготовить draft"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Состояние потока</h3>
|
||||||
|
|
||||||
|
{context?.current_is_draft ? (
|
||||||
|
<div className="state-block state-block-success">
|
||||||
|
<strong>Работаем в current draft.</strong>
|
||||||
|
<div className="muted">`draft/ensure` сейчас не нужен, editor shell может читать draft read models напрямую.</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shouldEnsureDraft ? (
|
||||||
|
<div className="state-block state-block-warning">
|
||||||
|
<strong>Нужен ensure draft.</strong>
|
||||||
|
<div className="muted">Текущая версия опубликована, перед входом в editor нужно вызвать `POST /draft/ensure`.</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{rereadRequired ? (
|
||||||
|
<div className="state-block state-block-warning">
|
||||||
|
<strong>Требуется reread состояния.</strong>
|
||||||
|
<div className="muted">Backend вернул stale/conflict сигнал. optimistic flow остановлен, UI должен опираться на перечитанные `current` и `editor/context`.</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ensureDraftMutation.data ? (
|
||||||
|
<div className="state-block state-block-info">
|
||||||
|
<strong>
|
||||||
|
{ensureDraftMutation.data.created ? "Новый draft подготовлен." : "Draft уже был актуален."}
|
||||||
|
</strong>
|
||||||
|
<div className="muted">
|
||||||
|
Версия: {valueText(ensureDraftMutation.data.scheme_version_id)} / v{valueText(ensureDraftMutation.data.version_number)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>Действия жизненного цикла</h3>
|
<h3>Действия жизненного цикла</h3>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
@@ -90,7 +188,7 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => publishMutation.mutate()}
|
onClick={() => publishMutation.mutate({ expectedSchemeVersionId: currentVersionId })}
|
||||||
>
|
>
|
||||||
{publishMutation.isPending ? "Публикация..." : "Опубликовать текущую"}
|
{publishMutation.isPending ? "Публикация..." : "Опубликовать текущую"}
|
||||||
</button>
|
</button>
|
||||||
@@ -104,15 +202,6 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
{unpublishMutation.isPending ? "Снятие публикации..." : "Снять с публикации"}
|
{unpublishMutation.isPending ? "Снятие публикации..." : "Снять с публикации"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => createNextVersionMutation.mutate()}
|
|
||||||
>
|
|
||||||
{createNextVersionMutation.isPending ? "Создание..." : "Создать следующую черновую версию"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
@@ -127,28 +216,20 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(publishMutation.isError || unpublishMutation.isError || createNextVersionMutation.isError || rollbackMutation.isError) ? (
|
{lifecycleError ? (
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<ApiErrorView
|
<ApiErrorView
|
||||||
title="Ошибка действия жизненного цикла"
|
title="Ошибка действия жизненного цикла"
|
||||||
message={
|
message={getApiErrorMessage(lifecycleError)}
|
||||||
getApiErrorMessage(
|
|
||||||
publishMutation.error ??
|
|
||||||
unpublishMutation.error ??
|
|
||||||
createNextVersionMutation.error ??
|
|
||||||
rollbackMutation.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(publishMutation.data || unpublishMutation.data || createNextVersionMutation.data || rollbackMutation.data) ? (
|
{(publishMutation.data || unpublishMutation.data || rollbackMutation.data) ? (
|
||||||
<div style={{ marginTop: 12 }} className="code-box">
|
<div style={{ marginTop: 12 }} className="code-box">
|
||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
publishMutation.data ??
|
publishMutation.data ??
|
||||||
unpublishMutation.data ??
|
unpublishMutation.data ??
|
||||||
createNextVersionMutation.data ??
|
|
||||||
rollbackMutation.data,
|
rollbackMutation.data,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -156,6 +237,20 @@ export function SchemeOverviewTab({ schemeId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Debug visibility</h3>
|
||||||
|
<div className="debug-grid">
|
||||||
|
<div><strong>scheme_id:</strong> <span className="mono-cell">{schemeId}</span></div>
|
||||||
|
<div><strong>scheme_version_id:</strong> <span className="mono-cell">{valueText(currentVersionId)}</span></div>
|
||||||
|
<div><strong>current role:</strong> {localizeRole(authState.role)}</div>
|
||||||
|
<div><strong>current scheme status:</strong> {localizeStatus(scheme?.status as string | undefined)}</div>
|
||||||
|
<div><strong>current version status:</strong> {localizeStatus(current?.status as string | undefined)}</div>
|
||||||
|
<div><strong>current_is_draft:</strong> {boolText(context?.current_is_draft)}</div>
|
||||||
|
<div><strong>recommended_action:</strong> {localizeRecommendedAction(recommendedAction)}</div>
|
||||||
|
<div><strong>typed lifecycle error:</strong> {valueText(lifecycleError ? normalizeApiError(lifecycleError).code : null)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { SchemeAuditTab } from "../features/schemes/SchemeAuditTab";
|
import { SchemeAuditTab } from "../features/schemes/SchemeAuditTab";
|
||||||
|
import { SchemeEditorTab } from "../features/schemes/SchemeEditorTab";
|
||||||
import { SchemeOverviewTab } from "../features/schemes/SchemeOverviewTab";
|
import { SchemeOverviewTab } from "../features/schemes/SchemeOverviewTab";
|
||||||
import { SchemePricingTab } from "../features/schemes/SchemePricingTab";
|
import { SchemePricingTab } from "../features/schemes/SchemePricingTab";
|
||||||
import { SchemeStructureTab } from "../features/schemes/SchemeStructureTab";
|
import { SchemeStructureTab } from "../features/schemes/SchemeStructureTab";
|
||||||
@@ -8,11 +9,11 @@ import { SchemeTestModeTab } from "../features/schemes/SchemeTestModeTab";
|
|||||||
import { SchemeVersionsTab } from "../features/schemes/SchemeVersionsTab";
|
import { SchemeVersionsTab } from "../features/schemes/SchemeVersionsTab";
|
||||||
import { SchemeViewerTab } from "../features/schemes/SchemeViewerTab";
|
import { SchemeViewerTab } from "../features/schemes/SchemeViewerTab";
|
||||||
|
|
||||||
type DetailTab = "viewer" | "overview" | "versions" | "structure" | "pricing" | "test" | "audit";
|
type DetailTab = "overview" | "editor" | "viewer" | "versions" | "structure" | "pricing" | "test" | "audit";
|
||||||
|
|
||||||
export function SchemeDetailPage() {
|
export function SchemeDetailPage() {
|
||||||
const { schemeId } = useParams();
|
const { schemeId } = useParams();
|
||||||
const [tab, setTab] = useState<DetailTab>("viewer");
|
const [tab, setTab] = useState<DetailTab>("overview");
|
||||||
|
|
||||||
if (!schemeId) {
|
if (!schemeId) {
|
||||||
return (
|
return (
|
||||||
@@ -33,8 +34,9 @@ export function SchemeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<button type="button" className={`btn ${tab === "viewer" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("viewer")}>Просмотр схемы</button>
|
|
||||||
<button type="button" className={`btn ${tab === "overview" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("overview")}>Обзор</button>
|
<button type="button" className={`btn ${tab === "overview" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("overview")}>Обзор</button>
|
||||||
|
<button type="button" className={`btn ${tab === "editor" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("editor")}>Редактор</button>
|
||||||
|
<button type="button" className={`btn ${tab === "viewer" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("viewer")}>Просмотр схемы</button>
|
||||||
<button type="button" className={`btn ${tab === "versions" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("versions")}>Версии</button>
|
<button type="button" className={`btn ${tab === "versions" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("versions")}>Версии</button>
|
||||||
<button type="button" className={`btn ${tab === "structure" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("structure")}>Структура</button>
|
<button type="button" className={`btn ${tab === "structure" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("structure")}>Структура</button>
|
||||||
<button type="button" className={`btn ${tab === "pricing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("pricing")}>Тарифы</button>
|
<button type="button" className={`btn ${tab === "pricing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("pricing")}>Тарифы</button>
|
||||||
@@ -44,8 +46,9 @@ export function SchemeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "overview" ? <SchemeOverviewTab schemeId={schemeId} onOpenEditor={() => setTab("editor")} /> : null}
|
||||||
|
{tab === "editor" ? <SchemeEditorTab schemeId={schemeId} /> : null}
|
||||||
{tab === "viewer" ? <SchemeViewerTab schemeId={schemeId} /> : null}
|
{tab === "viewer" ? <SchemeViewerTab schemeId={schemeId} /> : null}
|
||||||
{tab === "overview" ? <SchemeOverviewTab schemeId={schemeId} /> : null}
|
|
||||||
{tab === "versions" ? <SchemeVersionsTab schemeId={schemeId} /> : null}
|
{tab === "versions" ? <SchemeVersionsTab schemeId={schemeId} /> : null}
|
||||||
{tab === "structure" ? <SchemeStructureTab schemeId={schemeId} /> : null}
|
{tab === "structure" ? <SchemeStructureTab schemeId={schemeId} /> : null}
|
||||||
{tab === "pricing" ? <SchemePricingTab schemeId={schemeId} /> : null}
|
{tab === "pricing" ? <SchemePricingTab schemeId={schemeId} /> : null}
|
||||||
|
|||||||
63
src/shared/auth/AuthProvider.tsx
Normal file
63
src/shared/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
import { useAuthMeQuery, useManifestQuery } from "../../api/queries";
|
||||||
|
import { normalizeApiError } from "../../api/errors";
|
||||||
|
import type { ManifestResponse, UserRole } from "../types/api";
|
||||||
|
|
||||||
|
type CapabilityState = {
|
||||||
|
role: UserRole | null;
|
||||||
|
authHeaderName: string;
|
||||||
|
canAccessProduct: boolean;
|
||||||
|
canAccessAdmin: boolean;
|
||||||
|
manifest: ManifestResponse | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
authError: ReturnType<typeof normalizeApiError> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<CapabilityState | null>(null);
|
||||||
|
|
||||||
|
function deriveCapabilities(role: string | undefined): Pick<CapabilityState, "role" | "canAccessProduct" | "canAccessAdmin"> {
|
||||||
|
const normalizedRole = role?.trim().toLowerCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: normalizedRole ?? null,
|
||||||
|
canAccessProduct: normalizedRole === "admin" || normalizedRole === "operator" || normalizedRole === "viewer",
|
||||||
|
canAccessAdmin: normalizedRole === "admin"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const authQuery = useAuthMeQuery();
|
||||||
|
const manifestQuery = useManifestQuery();
|
||||||
|
|
||||||
|
const value = useMemo<CapabilityState>(() => {
|
||||||
|
const capabilities = deriveCapabilities(authQuery.data?.role);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...capabilities,
|
||||||
|
authHeaderName: authQuery.data?.auth_header ?? manifestQuery.data?.auth_header_name ?? "X-API-Key",
|
||||||
|
manifest: manifestQuery.data,
|
||||||
|
isLoading: authQuery.isLoading || manifestQuery.isLoading,
|
||||||
|
authError: authQuery.isError ? normalizeApiError(authQuery.error) : null
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
authQuery.data?.auth_header,
|
||||||
|
authQuery.data?.role,
|
||||||
|
authQuery.error,
|
||||||
|
authQuery.isError,
|
||||||
|
authQuery.isLoading,
|
||||||
|
manifestQuery.data?.auth_header_name,
|
||||||
|
manifestQuery.isLoading
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthState() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuthState must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ function requireEnv(name: string, value: string | undefined): string {
|
|||||||
|
|
||||||
export function getAppConfig(): AppConfig {
|
export function getAppConfig(): AppConfig {
|
||||||
return {
|
return {
|
||||||
appTitle: import.meta.env.VITE_APP_TITLE || "SVG Service Admin UI",
|
appTitle: import.meta.env.VITE_APP_TITLE || "SVG Service Frontend",
|
||||||
apiBaseUrl: requireEnv("VITE_API_BASE_URL", import.meta.env.VITE_API_BASE_URL),
|
apiBaseUrl: requireEnv("VITE_API_BASE_URL", import.meta.env.VITE_API_BASE_URL),
|
||||||
apiKey: requireEnv("VITE_API_KEY", import.meta.env.VITE_API_KEY)
|
apiKey: requireEnv("VITE_API_KEY", import.meta.env.VITE_API_KEY)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,3 +41,20 @@ export function localizeRole(value: string | null | undefined): string {
|
|||||||
|
|
||||||
return map[normalized] ?? value;
|
return map[normalized] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function localizeRecommendedAction(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
use_current_draft: "Работать в текущем draft",
|
||||||
|
create_draft: "Подготовить draft",
|
||||||
|
reread_required: "Перечитать состояние",
|
||||||
|
blocked: "Операция недоступна"
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[normalized] ?? value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type AuthMeResponse = {
|
|||||||
auth_header: string;
|
auth_header: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserRole = "admin" | "operator" | "viewer" | string;
|
||||||
|
|
||||||
export type ManifestResponse = {
|
export type ManifestResponse = {
|
||||||
service?: string;
|
service?: string;
|
||||||
api_prefix?: string;
|
api_prefix?: string;
|
||||||
@@ -21,8 +23,15 @@ export type ApiValidationFieldError = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiErrorDetailObject = {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiErrorPayload = {
|
export type ApiErrorPayload = {
|
||||||
detail?: string;
|
detail?: string | ApiErrorDetailObject;
|
||||||
errors?: ApiValidationFieldError[];
|
errors?: ApiValidationFieldError[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
@@ -255,5 +264,97 @@ export type LifecycleActionResponse = {
|
|||||||
version_number?: number;
|
version_number?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
normalized_storage_path?: string;
|
normalized_storage_path?: string;
|
||||||
|
created?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchemeEditorContextResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
current_scheme_version_id?: string;
|
||||||
|
current_version_number?: number | null;
|
||||||
|
scheme_status?: string | null;
|
||||||
|
scheme_version_status?: string | null;
|
||||||
|
current_is_draft?: boolean;
|
||||||
|
create_draft_available?: boolean;
|
||||||
|
recommended_action?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftSummaryResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
version_number?: number | null;
|
||||||
|
status?: string | null;
|
||||||
|
total_seats?: number | null;
|
||||||
|
total_groups?: number | null;
|
||||||
|
total_sectors?: number | null;
|
||||||
|
validation_summary?: Record<string, unknown> | null;
|
||||||
|
structure_diff_summary?: Record<string, unknown> | null;
|
||||||
|
publish_readiness?: Record<string, unknown> | null;
|
||||||
|
readiness_summary?: Record<string, unknown> | null;
|
||||||
|
aggregate_counts?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStructureSectorItem = {
|
||||||
|
sector_id?: string | null;
|
||||||
|
element_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStructureGroupItem = {
|
||||||
|
group_id?: string | null;
|
||||||
|
element_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStructureSeatItem = {
|
||||||
|
seat_id?: string | null;
|
||||||
|
element_id?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
row_label?: string | null;
|
||||||
|
seat_number?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStructureResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
version_number?: number | null;
|
||||||
|
sectors?: DraftStructureSectorItem[];
|
||||||
|
groups?: DraftStructureGroupItem[];
|
||||||
|
seats?: DraftStructureSeatItem[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationIssue = {
|
||||||
|
code?: string | null;
|
||||||
|
level?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftValidationResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
is_publishable?: boolean | null;
|
||||||
|
warnings?: ValidationIssue[];
|
||||||
|
issues?: ValidationIssue[];
|
||||||
|
indicators?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftComparePreviewResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
baseline_scheme_version_id?: string | null;
|
||||||
|
baseline_version_number?: number | null;
|
||||||
|
has_structure_changes?: boolean | null;
|
||||||
|
diff_summary?: Record<string, unknown> | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user