From 796f0f4af189859b6309ea7dd341cff39f4f015e Mon Sep 17 00:00:00 2001 From: greebo Date: Fri, 20 Mar 2026 18:51:21 +0300 Subject: [PATCH] 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 --- .env.example | 2 +- doc/README.md | 260 ++++++++ doc/frontend-integration-contract.md | 528 ++++++++++++++++ doc/smoke-regression.md | 673 +++++++++++++++++++++ src/api/client.ts | 21 + src/api/errors.ts | 250 ++++++++ src/api/queries.ts | 366 ++++++----- src/api/queryKeys.ts | 26 + src/api/reread.ts | 47 ++ src/app/App.tsx | 7 +- src/app/AppLayout.tsx | 26 +- src/app/styles.css | 30 + src/features/schemes/SchemeEditorTab.tsx | 441 ++++++++++++++ src/features/schemes/SchemeOverviewTab.tsx | 149 ++++- src/pages/SchemeDetailPage.tsx | 11 +- src/shared/auth/AuthProvider.tsx | 63 ++ src/shared/config/env.ts | 2 +- src/shared/lib/formatters.ts | 17 + src/shared/types/api.ts | 103 +++- src/vite-env.d.ts | 1 + 20 files changed, 2836 insertions(+), 187 deletions(-) create mode 100644 doc/README.md create mode 100644 doc/frontend-integration-contract.md create mode 100644 doc/smoke-regression.md create mode 100644 src/api/errors.ts create mode 100644 src/api/queryKeys.ts create mode 100644 src/api/reread.ts create mode 100644 src/features/schemes/SchemeEditorTab.tsx create mode 100644 src/shared/auth/AuthProvider.tsx create mode 100644 src/vite-env.d.ts diff --git a/.env.example b/.env.example index 61d168f..c615638 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..7f5402c --- /dev/null +++ b/doc/README.md @@ -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` diff --git a/doc/frontend-integration-contract.md b/doc/frontend-integration-contract.md new file mode 100644 index 0000000..2bcdfc6 --- /dev/null +++ b/doc/frontend-integration-contract.md @@ -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://: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 diff --git a/doc/smoke-regression.md b/doc/smoke-regression.md new file mode 100644 index 0000000..0fe7395 --- /dev/null +++ b/doc/smoke-regression.md @@ -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 diff --git a/src/api/client.ts b/src/api/client.ts index 0e1929d..4a1f100 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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(requestConfig: AxiosRequestConfig): Promise { + return (await apiClient.request(requestConfig)).data; +} + +export function apiGet(url: string, requestConfig?: AxiosRequestConfig): Promise { + return apiRequest({ ...requestConfig, method: "get", url }); +} + +export function apiPost(url: string, data?: unknown, requestConfig?: AxiosRequestConfig): Promise { + return apiRequest({ ...requestConfig, method: "post", url, data }); +} + +export function apiPut(url: string, data?: unknown, requestConfig?: AxiosRequestConfig): Promise { + return apiRequest({ ...requestConfig, method: "put", url, data }); +} + +export function apiDelete(url: string, requestConfig?: AxiosRequestConfig): Promise { + return apiRequest({ ...requestConfig, method: "delete", url }); +} diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..a527110 --- /dev/null +++ b/src/api/errors.ts @@ -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 | null; + fieldErrors: Record; + 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 { + return typeof value === "object" && value !== null; +} + +function isDetailObject(value: unknown): value is ApiErrorDetailObject { + return isObject(value); +} + +function pushFieldError(result: Record, field: string, message: string) { + if (!field || !message || result[field]) { + return; + } + result[field] = message; +} + +function collectFieldErrorsFromArray(result: Record, 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 { + const result: Record = {}; + + 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 | 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(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 { + 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 "Техническая ошибка"; +} diff --git a/src/api/queries.ts b/src/api/queries.ts index a52de94..96a60bc 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -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(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 { - if (!axios.isAxiosError(error)) return {}; - const errors = error.response?.data?.errors; - if (!Array.isArray(errors)) return {}; - - const result: Record = {}; - 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(payload: T[] | { items?: T[] } | null | undefined): T[] { if (Array.isArray(payload)) return payload; @@ -69,83 +41,178 @@ function getItems(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("/api/v1/auth/me")).data, + queryKey: queryKeys.authMe, + queryFn: async () => apiGet("/api/v1/auth/me"), retry: false }); } export function useManifestQuery() { return useQuery({ - queryKey: ["manifest"], - queryFn: async () => (await apiClient.get("/api/v1/manifest")).data, + queryKey: queryKeys.manifest, + queryFn: async () => apiGet("/api/v1/manifest"), retry: false }); } export function useSchemesQuery() { return useQuery({ - queryKey: ["schemes"], - queryFn: async () => (await apiClient.get("/api/v1/schemes")).data + queryKey: queryKeys.schemes, + queryFn: async () => apiGet("/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(`/api/v1/schemes/${schemeId}`)).data + queryFn: async () => apiGet(`/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(`/api/v1/schemes/${schemeId}/current`)).data + queryFn: async () => apiGet(`/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(`/api/v1/schemes/${schemeId}/versions`)).data) + queryFn: async () => getItems(await apiGet(`/api/v1/schemes/${schemeId}/versions`)) + }); +} + +export function useSchemeEditorContextQuery(schemeId: string | undefined) { + return useQuery({ + queryKey: queryKeys.schemeEditorContext(schemeId), + enabled: Boolean(schemeId), + queryFn: async () => apiGet(`/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( + `/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( + `/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( + `/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( + `/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(`/api/v1/schemes/${schemeId}/current/sectors`)).data) + queryFn: async () => getItems(await apiGet(`/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(`/api/v1/schemes/${schemeId}/current/groups`)).data) + queryFn: async () => getItems(await apiGet(`/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(`/api/v1/schemes/${schemeId}/current/seats`)).data) + queryFn: async () => getItems(await apiGet(`/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(`/api/v1/schemes/${schemeId}/pricing`)).data; + const data = await apiGet(`/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(`/api/v1/schemes/${schemeId}/audit`)).data) + queryFn: async () => getItems(await apiGet(`/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(`/api/v1/schemes/${schemeId}/current/svg/display/meta`, { + apiGet(`/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(`/api/v1/schemes/${schemeId}/current/svg/display`, { + return apiRequest({ + 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(`/api/v1/schemes/${schemeId}/current/svg`, { + return apiRequest({ + 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(`/api/v1/schemes/${schemeId}/test/seats/${seatId}`)).data + queryFn: async () => apiGet(`/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("/api/v1/schemes/upload", formData, { - headers: { "Content-Type": "multipart/form-data" } - }) - ).data; + const uploadResponse = await apiPost("/api/v1/schemes/upload", formData, { + headers: { "Content-Type": "multipart/form-data" } + }); + + if (uploadResponse.scheme_id || !uploadResponse.upload_id) { + return uploadResponse; + } + + const schemes = await apiGet("/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, 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(`/api/v1/schemes/${schemeId}/versions`)).data; + return apiPost(`/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(`/api/v1/schemes/${schemeId}/publish`)).data; + return apiPost(`/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(`/api/v1/schemes/${schemeId}/unpublish`)).data; + return apiPost(`/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(`/api/v1/schemes/${schemeId}/rollback`, { - target_version_number: targetVersionNumber - }) - ).data; + return apiPost(`/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(`/api/v1/schemes/${schemeId}/pricing/categories`, payload)).data; + return apiPost(`/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( - `/api/v1/schemes/${schemeId}/pricing/categories/${params.pricingCategoryId}`, - params.payload - ) - ).data; + return apiPut( + `/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(`/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(`/api/v1/schemes/${schemeId}/pricing/rules`, payload)).data; + return apiPost(`/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(`/api/v1/schemes/${schemeId}/pricing/rules/${params.priceRuleId}`, params.payload) - ).data; + return apiPut(`/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(`/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) }); } } }); diff --git a/src/api/queryKeys.ts b/src/api/queryKeys.ts new file mode 100644 index 0000000..7b494de --- /dev/null +++ b/src/api/queryKeys.ts @@ -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 +}; diff --git a/src/api/reread.ts b/src/api/reread.ts new file mode 100644 index 0000000..9b88f9a --- /dev/null +++ b/src/api/reread.ts @@ -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); +} diff --git a/src/app/App.tsx b/src/app/App.tsx index ba7ecbb..6cfa290 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,10 @@ import { AppRouter } from "./router"; +import { AuthProvider } from "../shared/auth/AuthProvider"; export function App() { - return ; + return ( + + + + ); } diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 6dfcf4f..7e81884 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -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 (
@@ -47,7 +45,7 @@ export function AppLayout() {
Схема зала
-
frontend v1
+
frontend integration shell
@@ -56,11 +54,11 @@ export function AppLayout() {
- {authError ? ( + {authState.authError ? (
) : ( diff --git a/src/app/styles.css b/src/app/styles.css index bb6183f..411173a 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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); diff --git a/src/features/schemes/SchemeEditorTab.tsx b/src/features/schemes/SchemeEditorTab.tsx new file mode 100644 index 0000000..90cde3f --- /dev/null +++ b/src/features/schemes/SchemeEditorTab.tsx @@ -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(null); + const [statusNote, setStatusNote] = useState(null); + const [lastConflictSignature, setLastConflictSignature] = useState(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
Загрузка editor flow...
; + } + + if (currentQuery.isError) { + return ; + } + + if (contextQuery.isError) { + return ; + } + + const structure = structureLists(structureQuery.data); + const validationState = issuesList(validationQuery.data); + const compareWithoutBaseline = compareBaselineMissing(compareQuery.data); + + return ( +
+
+
+
+

Редактор

+

+ Editor shell читает только backend read models: `draft/summary`, `draft/structure`, `draft/validation`, + `draft/compare-preview`. +

+
+
{editorReady ? "Draft shell active" : "Entry in progress"}
+
+ +
+ + + + +
+
+ + {statusNote ? ( +
+
+ Controlled flow status +
{statusNote}
+
+
+ ) : null} + + {!editorReady && shouldEnsureDraft ? ( +
+
+ Требуется подготовка draft. +
+ Пока `draft/ensure` не завершится и `current`/`editor/context` не будут перечитаны, editor shell не читает draft models. +
+
+
+ ) : null} + + {!editorReady && !shouldEnsureDraft ? ( +
+
+ Editor shell пока недоступен. +
+ Проверь `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)}. +
+
+
+ ) : null} + + {ensureDraftMutation.isError ? ( + + ) : null} + + {editorReady && (summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? ( +
Загрузка draft read models...
+ ) : null} + + {editorReady && summaryQuery.isError ? ( + + ) : null} + + {editorReady && structureQuery.isError ? ( + + ) : null} + + {editorReady && validationQuery.isError ? ( + + ) : null} + + {editorReady && compareQuery.isError ? ( + + ) : null} + + {editorReady && summaryQuery.data ? ( +
+

Summary

+
+ + + + +
+ +
+ + + + +
+ +
+ {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 + })} +
+
+ ) : null} + + {editorReady && structureQuery.data ? ( +
+

Structure

+
+ + + + +
+ +
+ + + + + + + + + + + + {structure.sectors.map((item, index) => ( + + + + + + + + ))} + + {structure.groups.map((item, index) => ( + + + + + + + + ))} + + {structure.seats.map((item, index) => ( + + + + + + + + ))} + + {structure.sectors.length === 0 && structure.groups.length === 0 && structure.seats.length === 0 ? ( + + + + ) : null} + +
EntityBusiness IDelement_idRelationsDetails
sector{sectorBusinessId(item)}{valueText(item.element_id)}{valueText(item.name)}
group{groupBusinessId(item)}{valueText(item.element_id)}sector_id={valueText(item.sector_id)}{valueText(item.name)}
seat{seatBusinessId(item)}{valueText(item.element_id)} + sector_id={valueText(item.sector_id)} / group_id={valueText(item.group_id)} + + row={valueText(item.row_label)} / seat={valueText(item.seat_number)} +
Draft structure returned empty lists.
+
+
+ ) : null} + + {editorReady && validationQuery.data ? ( +
+

Validation

+
+ + + + +
+ + {validationState.issues.length === 0 && validationState.warnings.length === 0 ? ( +
+ Validation issues не найдены. +
Пустой набор warnings/issues не считается ошибкой интеграции.
+
+ ) : null} + + {validationState.issues.length > 0 ? ( +
+ {prettyJson(validationState.issues)} +
+ ) : null} + + {validationState.warnings.length > 0 ? ( +
+ {prettyJson(validationState.warnings)} +
+ ) : null} + + {validationQuery.data.indicators ? ( +
+ {prettyJson(validationQuery.data.indicators)} +
+ ) : null} +
+ ) : null} + + {editorReady && compareQuery.data ? ( +
+

Compare preview

+
+ + + + +
+ + {compareWithoutBaseline ? ( +
+ Baseline отсутствует. +
Для fresh scheme compare-preview может вернуться без baseline, это controlled empty state, а не ошибка.
+
+ ) : ( +
+ {prettyJson(compareQuery.data)} +
+ )} +
+ ) : null} + +
+

Debug visibility

+
+
scheme_id: {schemeId}
+
scheme_version_id: {valueText(expectedSchemeVersionId)}
+
current role: {localizeRole(authState.role)}
+
current scheme status: {localizeStatus(contextQuery.data?.scheme_status)}
+
current version status: {localizeStatus(contextQuery.data?.scheme_version_status)}
+
current_is_draft: {boolText(contextQuery.data?.current_is_draft)}
+
recommended_action: {localizeRecommendedAction(contextQuery.data?.recommended_action)}
+
last conflict code: {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}
+
+
+
+ ); +} diff --git a/src/features/schemes/SchemeOverviewTab.tsx b/src/features/schemes/SchemeOverviewTab.tsx index f87bc31..c70fc74 100644 --- a/src/features/schemes/SchemeOverviewTab.tsx +++ b/src/features/schemes/SchemeOverviewTab.tsx @@ -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
Загрузка карточки схемы...
; } @@ -41,12 +59,31 @@ export function SchemeOverviewTab({ schemeId }: Props) { return ; } + if (contextQuery.isError) { + return ; + } + if (versionsQuery.isError) { return ; } 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 (
@@ -83,6 +120,67 @@ export function SchemeOverviewTab({ schemeId }: Props) {
+
+

Editor entry

+
+ + + + +
+ +
+ + + +
+
+ +
+

Состояние потока

+ + {context?.current_is_draft ? ( +
+ Работаем в current draft. +
`draft/ensure` сейчас не нужен, editor shell может читать draft read models напрямую.
+
+ ) : null} + + {shouldEnsureDraft ? ( +
+ Нужен ensure draft. +
Текущая версия опубликована, перед входом в editor нужно вызвать `POST /draft/ensure`.
+
+ ) : null} + + {rereadRequired ? ( +
+ Требуется reread состояния. +
Backend вернул stale/conflict сигнал. optimistic flow остановлен, UI должен опираться на перечитанные `current` и `editor/context`.
+
+ ) : null} + + {ensureDraftMutation.data ? ( +
+ + {ensureDraftMutation.data.created ? "Новый draft подготовлен." : "Draft уже был актуален."} + +
+ Версия: {valueText(ensureDraftMutation.data.scheme_version_id)} / v{valueText(ensureDraftMutation.data.version_number)} +
+
+ ) : null} +
+

Действия жизненного цикла

@@ -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 ? "Публикация..." : "Опубликовать текущую"} @@ -104,15 +202,6 @@ export function SchemeOverviewTab({ schemeId }: Props) { {unpublishMutation.isPending ? "Снятие публикации..." : "Снять с публикации"} - -
- {(publishMutation.isError || unpublishMutation.isError || createNextVersionMutation.isError || rollbackMutation.isError) ? ( + {lifecycleError ? (
) : null} - {(publishMutation.data || unpublishMutation.data || createNextVersionMutation.data || rollbackMutation.data) ? ( + {(publishMutation.data || unpublishMutation.data || rollbackMutation.data) ? (
{JSON.stringify( publishMutation.data ?? unpublishMutation.data ?? - createNextVersionMutation.data ?? rollbackMutation.data, null, 2 @@ -156,6 +237,20 @@ export function SchemeOverviewTab({ schemeId }: Props) {
) : null}
+ +
+

Debug visibility

+
+
scheme_id: {schemeId}
+
scheme_version_id: {valueText(currentVersionId)}
+
current role: {localizeRole(authState.role)}
+
current scheme status: {localizeStatus(scheme?.status as string | undefined)}
+
current version status: {localizeStatus(current?.status as string | undefined)}
+
current_is_draft: {boolText(context?.current_is_draft)}
+
recommended_action: {localizeRecommendedAction(recommendedAction)}
+
typed lifecycle error: {valueText(lifecycleError ? normalizeApiError(lifecycleError).code : null)}
+
+
); } diff --git a/src/pages/SchemeDetailPage.tsx b/src/pages/SchemeDetailPage.tsx index 5bece49..ebcabff 100644 --- a/src/pages/SchemeDetailPage.tsx +++ b/src/pages/SchemeDetailPage.tsx @@ -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("viewer"); + const [tab, setTab] = useState("overview"); if (!schemeId) { return ( @@ -33,8 +34,9 @@ export function SchemeDetailPage() {
- + + @@ -44,8 +46,9 @@ export function SchemeDetailPage() {
+ {tab === "overview" ? setTab("editor")} /> : null} + {tab === "editor" ? : null} {tab === "viewer" ? : null} - {tab === "overview" ? : null} {tab === "versions" ? : null} {tab === "structure" ? : null} {tab === "pricing" ? : null} diff --git a/src/shared/auth/AuthProvider.tsx b/src/shared/auth/AuthProvider.tsx new file mode 100644 index 0000000..ae63f45 --- /dev/null +++ b/src/shared/auth/AuthProvider.tsx @@ -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 | null; +}; + +const AuthContext = createContext(null); + +function deriveCapabilities(role: string | undefined): Pick { + 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(() => { + 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 {children}; +} + +export function useAuthState() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error("useAuthState must be used within AuthProvider"); + } + + return context; +} diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 61213c9..ecff44f 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -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) }; diff --git a/src/shared/lib/formatters.ts b/src/shared/lib/formatters.ts index c924d69..7bb7da0 100644 --- a/src/shared/lib/formatters.ts +++ b/src/shared/lib/formatters.ts @@ -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 = { + use_current_draft: "Работать в текущем draft", + create_draft: "Подготовить draft", + reread_required: "Перечитать состояние", + blocked: "Операция недоступна" + }; + + return map[normalized] ?? value; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 65dc9c4..3de0fd7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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 | 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 | null; + structure_diff_summary?: Record | null; + publish_readiness?: Record | null; + readiness_summary?: Record | null; + aggregate_counts?: Record | 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 | null; + [key: string]: unknown; +}; + +export type DraftValidationResponse = { + scheme_id?: string; + scheme_version_id?: string; + is_publishable?: boolean | null; + warnings?: ValidationIssue[]; + issues?: ValidationIssue[]; + indicators?: Record | 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 | null; [key: string]: unknown; }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +///