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:
greebo
2026-03-20 18:51:21 +03:00
parent 89e52e3193
commit 796f0f4af1
20 changed files with 2836 additions and 187 deletions

View File

@@ -1,4 +1,4 @@
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_KEY=admin-local-dev-key

260
doc/README.md Normal file
View 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`

View 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
View 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

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import type { AxiosRequestConfig } from "axios";
import { getAppConfig } from "../shared/config/env";
const config = getAppConfig();
@@ -10,3 +11,23 @@ export const apiClient = axios.create({
"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
View 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 "Техническая ошибка";
}

View File

@@ -1,12 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { apiClient } from "./client";
import type { QueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost, apiPut, apiRequest } from "./client";
import { getApiErrorMessage, getApiFieldErrors, isRereadRequiredConflict } from "./errors";
import { queryKeys } from "./queryKeys";
import { rereadSchemeState } from "./reread";
import type {
ApiErrorPayload,
AuthMeResponse,
CreatePriceRuleRequest,
CreatePricingCategoryRequest,
DisplaySvgMetaResponse,
DraftComparePreviewResponse,
DraftStructureResponse,
DraftSummaryResponse,
DraftValidationResponse,
LifecycleActionResponse,
ManifestResponse,
PriceRuleItem,
@@ -14,6 +20,7 @@ import type {
SchemeAuditResponse,
SchemeCurrentVersionResponse,
SchemeDetailResponse,
SchemeEditorContextResponse,
SchemeGroupsResponse,
SchemePricingResponse,
SchemeSeatsResponse,
@@ -26,42 +33,7 @@ import type {
UploadSchemeResponse
} from "../shared/types/api";
export function getApiErrorMessage(error: unknown): string {
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;
}
export { getApiErrorMessage, getApiFieldErrors } from "./errors";
function getItems<T>(payload: T[] | { items?: T[] } | null | undefined): T[] {
if (Array.isArray(payload)) return payload;
@@ -69,83 +41,178 @@ function getItems<T>(payload: T[] | { items?: T[] } | null | undefined): T[] {
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() {
return useQuery({
queryKey: ["auth-me"],
queryFn: async () => (await apiClient.get<AuthMeResponse>("/api/v1/auth/me")).data,
queryKey: queryKeys.authMe,
queryFn: async () => apiGet<AuthMeResponse>("/api/v1/auth/me"),
retry: false
});
}
export function useManifestQuery() {
return useQuery({
queryKey: ["manifest"],
queryFn: async () => (await apiClient.get<ManifestResponse>("/api/v1/manifest")).data,
queryKey: queryKeys.manifest,
queryFn: async () => apiGet<ManifestResponse>("/api/v1/manifest"),
retry: false
});
}
export function useSchemesQuery() {
return useQuery({
queryKey: ["schemes"],
queryFn: async () => (await apiClient.get<SchemesListResponse>("/api/v1/schemes")).data
queryKey: queryKeys.schemes,
queryFn: async () => apiGet<SchemesListResponse>("/api/v1/schemes")
});
}
export function useSchemeDetailQuery(schemeId: string | undefined) {
return useQuery({
queryKey: ["scheme-detail", schemeId],
queryKey: queryKeys.schemeDetail(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) {
return useQuery({
queryKey: ["scheme-current", schemeId],
queryKey: queryKeys.schemeCurrent(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) {
return useQuery({
queryKey: ["scheme-versions", schemeId],
queryKey: queryKeys.schemeVersions(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) {
return useQuery({
queryKey: ["scheme-sectors", schemeId],
queryKey: queryKeys.schemeSectors(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) {
return useQuery({
queryKey: ["scheme-groups", schemeId],
queryKey: queryKeys.schemeGroups(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) {
return useQuery({
queryKey: ["scheme-seats", schemeId],
queryKey: queryKeys.schemeSeats(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) {
return useQuery({
queryKey: ["scheme-pricing", schemeId],
queryKey: queryKeys.schemePricing(schemeId),
enabled: Boolean(schemeId),
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 ?? [] };
}
});
@@ -153,63 +220,63 @@ export function useSchemePricingQuery(schemeId: string | undefined) {
export function useSchemeAuditQuery(schemeId: string | undefined) {
return useQuery({
queryKey: ["scheme-audit", schemeId],
queryKey: queryKeys.schemeAudit(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) {
return useQuery({
queryKey: ["scheme-display-meta", schemeId, "passthrough"],
queryKey: queryKeys.schemeDisplayMeta(schemeId),
enabled: Boolean(schemeId),
retry: false,
queryFn: async () =>
(
await apiClient.get<DisplaySvgMetaResponse>(`/api/v1/schemes/${schemeId}/current/svg/display/meta`, {
apiGet<DisplaySvgMetaResponse>(`/api/v1/schemes/${schemeId}/current/svg/display/meta`, {
params: { mode: "passthrough" }
})
).data
})
});
}
export function useDisplaySvgQuery(schemeId: string | undefined) {
return useQuery({
queryKey: ["scheme-display-svg", schemeId, "passthrough"],
queryKey: queryKeys.schemeDisplaySvg(schemeId),
enabled: Boolean(schemeId),
retry: false,
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" },
responseType: "text",
transformResponse: [(data) => data]
});
return response.data;
}
});
}
export function useLegacySvgQuery(schemeId: string | undefined) {
return useQuery({
queryKey: ["scheme-legacy-svg", schemeId],
queryKey: queryKeys.schemeLegacySvg(schemeId),
enabled: Boolean(schemeId),
retry: false,
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",
transformResponse: [(data) => data]
});
return response.data;
}
});
}
export function useTestSeatPreviewQuery(schemeId: string | undefined, seatId: string | undefined) {
return useQuery({
queryKey: ["scheme-test-seat", schemeId, seatId],
queryKey: queryKeys.schemeTestSeat(schemeId, seatId),
enabled: Boolean(schemeId && seatId),
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) => {
const formData = new FormData();
formData.append("file", file);
return (
await apiClient.post<UploadSchemeResponse>("/api/v1/schemes/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }
})
).data;
const uploadResponse = await apiPost<UploadSchemeResponse>("/api/v1/schemes/upload", formData, {
headers: { "Content-Type": "multipart/form-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 () => {
await queryClient.invalidateQueries({ queryKey: ["schemes"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemes });
}
});
}
async function refetchSchemeBundle(queryClient: ReturnType<typeof useQueryClient>, schemeId: string) {
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) {
export function useEnsureDraftMutation(schemeId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
mutationFn: async (params?: { expectedCurrentSchemeVersionId?: string | null }) => {
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 () => {
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
onSuccess: async (data) => {
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) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
mutationFn: async (params?: { expectedSchemeVersionId?: string | null }) => {
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 () => {
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({
mutationFn: async () => {
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 () => {
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({
mutationFn: async (targetVersionNumber: number) => {
if (!schemeId) throw new Error("schemeId is required");
return (
await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/rollback`, {
target_version_number: targetVersionNumber
})
).data;
return apiPost<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/rollback`, {
target_version_number: targetVersionNumber
});
},
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({
mutationFn: async (payload: CreatePricingCategoryRequest) => {
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 () => {
if (schemeId) {
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
}
}
});
@@ -326,17 +420,15 @@ export function useUpdatePricingCategoryMutation(schemeId: string | undefined) {
return useMutation({
mutationFn: async (params: { pricingCategoryId: string; payload: UpdatePricingCategoryRequest }) => {
if (!schemeId) throw new Error("schemeId is required");
return (
await apiClient.put<PricingCategoryItem>(
`/api/v1/schemes/${schemeId}/pricing/categories/${params.pricingCategoryId}`,
params.payload
)
).data;
return apiPut<PricingCategoryItem>(
`/api/v1/schemes/${schemeId}/pricing/categories/${params.pricingCategoryId}`,
params.payload
);
},
onSuccess: async () => {
if (schemeId) {
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
}
}
});
@@ -347,13 +439,13 @@ export function useDeletePricingCategoryMutation(schemeId: string | undefined) {
return useMutation({
mutationFn: async (pricingCategoryId: string) => {
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 };
},
onSuccess: async () => {
if (schemeId) {
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
}
}
});
@@ -364,12 +456,12 @@ export function useCreatePriceRuleMutation(schemeId: string | undefined) {
return useMutation({
mutationFn: async (payload: CreatePriceRuleRequest) => {
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 () => {
if (schemeId) {
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
}
}
});
@@ -380,14 +472,12 @@ export function useUpdatePriceRuleMutation(schemeId: string | undefined) {
return useMutation({
mutationFn: async (params: { priceRuleId: string; payload: UpdatePriceRuleRequest }) => {
if (!schemeId) throw new Error("schemeId is required");
return (
await apiClient.put<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules/${params.priceRuleId}`, params.payload)
).data;
return apiPut<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules/${params.priceRuleId}`, params.payload);
},
onSuccess: async () => {
if (schemeId) {
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
}
}
});
@@ -398,13 +488,13 @@ export function useDeletePriceRuleMutation(schemeId: string | undefined) {
return useMutation({
mutationFn: async (priceRuleId: string) => {
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 };
},
onSuccess: async () => {
if (schemeId) {
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemePricing(schemeId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.schemeAudit(schemeId) });
}
}
});

26
src/api/queryKeys.ts Normal file
View 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
View 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);
}

View File

@@ -1,5 +1,10 @@
import { AppRouter } from "./router";
import { AuthProvider } from "../shared/auth/AuthProvider";
export function App() {
return <AppRouter />;
return (
<AuthProvider>
<AppRouter />
</AuthProvider>
);
}

View File

@@ -1,23 +1,19 @@
import { Link, NavLink, Outlet } from "react-router-dom";
import { useAuthMeQuery, useManifestQuery, getApiErrorMessage } from "../api/queries";
import { getAppConfig } from "../shared/config/env";
import { ApiErrorView } from "../shared/ui/ApiErrorView";
import { localizeRole } from "../shared/lib/formatters";
import { useAuthState } from "../shared/auth/AuthProvider";
export function AppLayout() {
const config = getAppConfig();
const authQuery = useAuthMeQuery();
const manifestQuery = useManifestQuery();
const authError = authQuery.isError ? getApiErrorMessage(authQuery.error) : null;
const manifest = manifestQuery.data;
const authState = useAuthState();
return (
<div className="shell">
<aside className="sidebar">
<div className="logo-block">
<div className="logo-title">{config.appTitle}</div>
<div className="logo-subtitle">Админка / тестовый интерфейс</div>
<div className="logo-subtitle">Product integration shell</div>
</div>
<nav className="menu">
@@ -32,14 +28,16 @@ export function AppLayout() {
<div className="sidebar-box">
<div className="sidebar-box-title">Подключение</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>{authQuery.data?.auth_header ?? manifest?.auth_header_name ?? "X-API-Key"}</span></div>
<div className="kv"><span>Роль</span><span>{authState.isLoading ? "..." : localizeRole(authState.role) ?? "ошибка"}</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 className="sidebar-box">
<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>{manifest?.svg_limits?.max_elements ?? "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>{authState.manifest?.svg_limits?.max_elements ?? "n/a"}</span></div>
</div>
</aside>
@@ -47,7 +45,7 @@ export function AppLayout() {
<header className="topbar">
<div>
<div className="page-title">Схема зала</div>
<div className="page-subtitle">frontend v1</div>
<div className="page-subtitle">frontend integration shell</div>
</div>
<div className="top-actions">
@@ -56,11 +54,11 @@ export function AppLayout() {
</div>
</header>
{authError ? (
{authState.authError ? (
<main className="main">
<ApiErrorView
title="Ошибка доступа"
message={`${authError}. Проверь VITE_API_BASE_URL и VITE_API_KEY.`}
message={`${authState.authError.message}. Проверь VITE_API_BASE_URL и VITE_API_KEY.`}
/>
</main>
) : (

View File

@@ -229,6 +229,30 @@ select {
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 {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
@@ -428,6 +452,12 @@ select {
gap: 8px;
margin-top: 10px;
}\n
.debug-grid {
display: grid;
gap: 8px;
}
.viewer-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);

View 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>
);
}

View File

@@ -1,20 +1,38 @@
import { getApiErrorMessage, useCreateNextVersionMutation, usePublishSchemeMutation, useRollbackSchemeMutation, useSchemeCurrentQuery, useSchemeDetailQuery, useSchemeVersionsQuery, useUnpublishSchemeMutation } from "../../api/queries";
import { localizeStatus } from "../../shared/lib/formatters";
import { getApiErrorMessage, useEnsureDraftMutation, usePublishSchemeMutation, useRollbackSchemeMutation, useSchemeCurrentQuery, useSchemeDetailQuery, useSchemeEditorContextQuery, useSchemeVersionsQuery, useUnpublishSchemeMutation } from "../../api/queries";
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 { StatCard } from "../../shared/ui/StatCard";
type Props = {
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 currentQuery = useSchemeCurrentQuery(schemeId);
const contextQuery = useSchemeEditorContextQuery(schemeId);
const versionsQuery = useSchemeVersionsQuery(schemeId);
const ensureDraftMutation = useEnsureDraftMutation(schemeId);
const publishMutation = usePublishSchemeMutation(schemeId);
const unpublishMutation = useUnpublishSchemeMutation(schemeId);
const createNextVersionMutation = useCreateNextVersionMutation(schemeId);
const rollbackMutation = useRollbackSchemeMutation(schemeId);
const versions = versionsQuery.data ?? [];
@@ -24,12 +42,12 @@ export function SchemeOverviewTab({ schemeId }: Props) {
.sort((a, b) => b - a)[0];
const isBusy =
ensureDraftMutation.isPending ||
publishMutation.isPending ||
unpublishMutation.isPending ||
createNextVersionMutation.isPending ||
rollbackMutation.isPending;
if (detailQuery.isLoading || currentQuery.isLoading || versionsQuery.isLoading) {
if (detailQuery.isLoading || currentQuery.isLoading || contextQuery.isLoading || versionsQuery.isLoading) {
return <div className="panel">Загрузка карточки схемы...</div>;
}
@@ -41,12 +59,31 @@ export function SchemeOverviewTab({ schemeId }: Props) {
return <ApiErrorView title="Ошибка загрузки текущей версии" message={getApiErrorMessage(currentQuery.error)} />;
}
if (contextQuery.isError) {
return <ApiErrorView title="Ошибка загрузки editor context" message={getApiErrorMessage(contextQuery.error)} />;
}
if (versionsQuery.isError) {
return <ApiErrorView title="Ошибка загрузки версий" message={getApiErrorMessage(versionsQuery.error)} />;
}
const scheme = detailQuery.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 (
<div className="detail-grid">
@@ -83,6 +120,67 @@ export function SchemeOverviewTab({ schemeId }: Props) {
</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">
<h3>Действия жизненного цикла</h3>
<div className="toolbar">
@@ -90,7 +188,7 @@ export function SchemeOverviewTab({ schemeId }: Props) {
type="button"
className="btn btn-primary"
disabled={isBusy}
onClick={() => publishMutation.mutate()}
onClick={() => publishMutation.mutate({ expectedSchemeVersionId: currentVersionId })}
>
{publishMutation.isPending ? "Публикация..." : "Опубликовать текущую"}
</button>
@@ -104,15 +202,6 @@ export function SchemeOverviewTab({ schemeId }: Props) {
{unpublishMutation.isPending ? "Снятие публикации..." : "Снять с публикации"}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={isBusy}
onClick={() => createNextVersionMutation.mutate()}
>
{createNextVersionMutation.isPending ? "Создание..." : "Создать следующую черновую версию"}
</button>
<button
type="button"
className="btn btn-danger"
@@ -127,28 +216,20 @@ export function SchemeOverviewTab({ schemeId }: Props) {
</button>
</div>
{(publishMutation.isError || unpublishMutation.isError || createNextVersionMutation.isError || rollbackMutation.isError) ? (
{lifecycleError ? (
<div style={{ marginTop: 12 }}>
<ApiErrorView
title="Ошибка действия жизненного цикла"
message={
getApiErrorMessage(
publishMutation.error ??
unpublishMutation.error ??
createNextVersionMutation.error ??
rollbackMutation.error
)
}
message={getApiErrorMessage(lifecycleError)}
/>
</div>
) : null}
{(publishMutation.data || unpublishMutation.data || createNextVersionMutation.data || rollbackMutation.data) ? (
{(publishMutation.data || unpublishMutation.data || rollbackMutation.data) ? (
<div style={{ marginTop: 12 }} className="code-box">
{JSON.stringify(
publishMutation.data ??
unpublishMutation.data ??
createNextVersionMutation.data ??
rollbackMutation.data,
null,
2
@@ -156,6 +237,20 @@ export function SchemeOverviewTab({ schemeId }: Props) {
</div>
) : null}
</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>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { SchemeAuditTab } from "../features/schemes/SchemeAuditTab";
import { SchemeEditorTab } from "../features/schemes/SchemeEditorTab";
import { SchemeOverviewTab } from "../features/schemes/SchemeOverviewTab";
import { SchemePricingTab } from "../features/schemes/SchemePricingTab";
import { SchemeStructureTab } from "../features/schemes/SchemeStructureTab";
@@ -8,11 +9,11 @@ import { SchemeTestModeTab } from "../features/schemes/SchemeTestModeTab";
import { SchemeVersionsTab } from "../features/schemes/SchemeVersionsTab";
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() {
const { schemeId } = useParams();
const [tab, setTab] = useState<DetailTab>("viewer");
const [tab, setTab] = useState<DetailTab>("overview");
if (!schemeId) {
return (
@@ -33,8 +34,9 @@ export function SchemeDetailPage() {
</div>
<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 === "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 === "structure" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("structure")}>Структура</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>
{tab === "overview" ? <SchemeOverviewTab schemeId={schemeId} onOpenEditor={() => setTab("editor")} /> : null}
{tab === "editor" ? <SchemeEditorTab schemeId={schemeId} /> : null}
{tab === "viewer" ? <SchemeViewerTab schemeId={schemeId} /> : null}
{tab === "overview" ? <SchemeOverviewTab schemeId={schemeId} /> : null}
{tab === "versions" ? <SchemeVersionsTab schemeId={schemeId} /> : null}
{tab === "structure" ? <SchemeStructureTab schemeId={schemeId} /> : null}
{tab === "pricing" ? <SchemePricingTab schemeId={schemeId} /> : null}

View 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;
}

View File

@@ -13,7 +13,7 @@ function requireEnv(name: string, value: string | undefined): string {
export function getAppConfig(): AppConfig {
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),
apiKey: requireEnv("VITE_API_KEY", import.meta.env.VITE_API_KEY)
};

View File

@@ -41,3 +41,20 @@ export function localizeRole(value: string | null | undefined): string {
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;
}

View File

@@ -3,6 +3,8 @@ export type AuthMeResponse = {
auth_header: string;
};
export type UserRole = "admin" | "operator" | "viewer" | string;
export type ManifestResponse = {
service?: string;
api_prefix?: string;
@@ -21,8 +23,15 @@ export type ApiValidationFieldError = {
message: string;
};
export type ApiErrorDetailObject = {
code?: string;
message?: string;
details?: Record<string, unknown> | null;
[key: string]: unknown;
};
export type ApiErrorPayload = {
detail?: string;
detail?: string | ApiErrorDetailObject;
errors?: ApiValidationFieldError[];
[key: string]: unknown;
};
@@ -255,5 +264,97 @@ export type LifecycleActionResponse = {
version_number?: number;
status?: 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;
};

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />