diff --git a/backend/app/api/routes/editor.py b/backend/app/api/routes/editor.py index e5f8801..16e0cc5 100644 --- a/backend/app/api/routes/editor.py +++ b/backend/app/api/routes/editor.py @@ -24,6 +24,8 @@ from app.repositories.scheme_sectors import ( list_scheme_version_sectors, update_scheme_version_sector_by_record_id, ) +from app.repositories.scheme_versions import get_current_scheme_version +from app.repositories.schemes import get_scheme_record_by_scheme_id from app.schemas.editor import ( BulkSeatPatchRequest, BulkSeatPatchResponse, @@ -37,6 +39,8 @@ from app.schemas.editor import ( DraftSeatItem, DraftSectorItem, DraftStructureResponse, + DraftSummaryResponse, + EditorContextResponse, GroupPatchRequest, GroupPatchResponse, RepairReferencesResponse, @@ -52,13 +56,12 @@ from app.services.draft_guard import get_current_draft_context from app.services.editor_validation import ( validate_bulk_seat_patch_references, validate_bulk_seat_patch_uniqueness, - validate_create_group_uniqueness, - validate_create_sector_uniqueness, validate_group_patch_uniqueness, validate_sector_patch_uniqueness, validate_single_seat_patch_references, validate_single_seat_patch_uniqueness, ) +from app.services.publish_readiness import build_publish_readiness from app.services.scheme_validation import build_scheme_validation_report from app.services.structure_diff import build_structure_diff from app.services.structure_sync import repair_structure_references @@ -66,6 +69,123 @@ from app.services.structure_sync import repair_structure_references router = APIRouter() +def _seat_item(row) -> DraftSeatItem: + return DraftSeatItem( + seat_record_id=row.seat_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + tag=row.tag, + classes_raw=row.classes_raw, + x=row.x, + y=row.y, + cx=row.cx, + cy=row.cy, + width=row.width, + height=row.height, + created_at=row.created_at.isoformat(), + ) + + +def _sector_item(row) -> DraftSectorItem: + return DraftSectorItem( + sector_record_id=row.sector_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + + +def _group_item(row) -> DraftGroupItem: + return DraftGroupItem( + group_record_id=row.group_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/editor/context", response_model=EditorContextResponse) +async def get_editor_context( + scheme_id: str, + role: str = Depends(require_api_key), +): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + + current_is_draft = scheme.status == "draft" and version.status == "draft" + + return EditorContextResponse( + scheme_id=scheme.scheme_id, + current_scheme_version_id=version.scheme_version_id, + current_version_number=version.version_number, + scheme_status=scheme.status, + scheme_version_status=version.status, + editor_available=True, + current_is_draft=current_is_draft, + create_draft_available=not current_is_draft, + recommended_action="use_current_draft" if current_is_draft else "create_draft", + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/summary", response_model=DraftSummaryResponse) +async def get_draft_summary( + scheme_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + + seats = await list_scheme_version_seats(version.scheme_version_id) + sectors = await list_scheme_version_sectors(version.scheme_version_id) + groups = await list_scheme_version_groups(version.scheme_version_id) + + validation = await build_scheme_validation_report( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + structure_diff = await build_structure_diff( + scheme_id=scheme.scheme_id, + draft_scheme_version_id=version.scheme_version_id, + ) + readiness = await build_publish_readiness( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + status=version.status, + ) + + return DraftSummaryResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + status=version.status, + total_seats=len(seats), + total_sectors=len(sectors), + total_groups=len(groups), + validation_summary=validation["summary"], + structure_diff_summary=structure_diff["summary"], + publish_readiness=readiness["readiness"], + ) + + @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/structure", response_model=DraftStructureResponse) async def get_draft_structure( scheme_id: str, @@ -84,55 +204,9 @@ async def get_draft_structure( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, status=version.status, - seats=[ - DraftSeatItem( - seat_record_id=row.seat_record_id, - scheme_id=row.scheme_id, - scheme_version_id=row.scheme_version_id, - element_id=row.element_id, - seat_id=row.seat_id, - sector_id=row.sector_id, - group_id=row.group_id, - row_label=row.row_label, - seat_number=row.seat_number, - tag=row.tag, - classes_raw=row.classes_raw, - x=row.x, - y=row.y, - cx=row.cx, - cy=row.cy, - width=row.width, - height=row.height, - created_at=row.created_at.isoformat(), - ) - for row in seats - ], - sectors=[ - DraftSectorItem( - sector_record_id=row.sector_record_id, - scheme_id=row.scheme_id, - scheme_version_id=row.scheme_version_id, - element_id=row.element_id, - sector_id=row.sector_id, - name=row.name, - classes_raw=row.classes_raw, - created_at=row.created_at.isoformat(), - ) - for row in sectors - ], - groups=[ - DraftGroupItem( - group_record_id=row.group_record_id, - scheme_id=row.scheme_id, - scheme_version_id=row.scheme_version_id, - element_id=row.element_id, - group_id=row.group_id, - name=row.name, - classes_raw=row.classes_raw, - created_at=row.created_at.isoformat(), - ) - for row in groups - ], + seats=[_seat_item(row) for row in seats], + sectors=[_sector_item(row) for row in sectors], + groups=[_group_item(row) for row in groups], total_seats=len(seats), total_sectors=len(sectors), total_groups=len(groups), @@ -161,97 +235,6 @@ async def get_draft_validation( } -@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=DraftSeatItem) -async def get_draft_seat_by_record_id( - scheme_id: str, - seat_record_id: str, - expected_scheme_version_id: str | None = Query(default=None), - role: str = Depends(require_api_key), -): - _scheme, version = await get_current_draft_context( - scheme_id, - expected_scheme_version_id=expected_scheme_version_id, - ) - row = await get_scheme_version_seat_by_record_id( - scheme_version_id=version.scheme_version_id, - seat_record_id=seat_record_id, - ) - return DraftSeatItem( - seat_record_id=row.seat_record_id, - scheme_id=row.scheme_id, - scheme_version_id=row.scheme_version_id, - element_id=row.element_id, - seat_id=row.seat_id, - sector_id=row.sector_id, - group_id=row.group_id, - row_label=row.row_label, - seat_number=row.seat_number, - tag=row.tag, - classes_raw=row.classes_raw, - x=row.x, - y=row.y, - cx=row.cx, - cy=row.cy, - width=row.width, - height=row.height, - created_at=row.created_at.isoformat(), - ) - - -@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=DraftSectorItem) -async def get_draft_sector_by_record_id( - scheme_id: str, - sector_record_id: str, - expected_scheme_version_id: str | None = Query(default=None), - role: str = Depends(require_api_key), -): - _scheme, version = await get_current_draft_context( - scheme_id, - expected_scheme_version_id=expected_scheme_version_id, - ) - row = await get_scheme_version_sector_by_record_id( - scheme_version_id=version.scheme_version_id, - sector_record_id=sector_record_id, - ) - return DraftSectorItem( - sector_record_id=row.sector_record_id, - scheme_id=row.scheme_id, - scheme_version_id=row.scheme_version_id, - element_id=row.element_id, - sector_id=row.sector_id, - name=row.name, - classes_raw=row.classes_raw, - created_at=row.created_at.isoformat(), - ) - - -@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=DraftGroupItem) -async def get_draft_group_by_record_id( - scheme_id: str, - group_record_id: str, - expected_scheme_version_id: str | None = Query(default=None), - role: str = Depends(require_api_key), -): - _scheme, version = await get_current_draft_context( - scheme_id, - expected_scheme_version_id=expected_scheme_version_id, - ) - row = await get_scheme_version_group_by_record_id( - scheme_version_id=version.scheme_version_id, - group_record_id=group_record_id, - ) - return DraftGroupItem( - group_record_id=row.group_record_id, - scheme_id=row.scheme_id, - scheme_version_id=row.scheme_version_id, - element_id=row.element_id, - group_id=row.group_id, - name=row.name, - classes_raw=row.classes_raw, - created_at=row.created_at.isoformat(), - ) - - @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/compare-preview", response_model=StructureDiffResponse) async def get_draft_compare_preview( scheme_id: str, @@ -277,6 +260,60 @@ async def get_draft_compare_preview( ) +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=DraftSeatItem) +async def get_draft_seat_by_record_id( + scheme_id: str, + seat_record_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + _scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + row = await get_scheme_version_seat_by_record_id( + scheme_version_id=version.scheme_version_id, + seat_record_id=seat_record_id, + ) + return _seat_item(row) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=DraftSectorItem) +async def get_draft_sector_by_record_id( + scheme_id: str, + sector_record_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + _scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + row = await get_scheme_version_sector_by_record_id( + scheme_version_id=version.scheme_version_id, + sector_record_id=sector_record_id, + ) + return _sector_item(row) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=DraftGroupItem) +async def get_draft_group_by_record_id( + scheme_id: str, + group_record_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + _scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + row = await get_scheme_version_group_by_record_id( + scheme_version_id=version.scheme_version_id, + group_record_id=group_record_id, + ) + return _group_item(row) + + @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors", response_model=CreateSectorResponse) async def create_draft_sector( scheme_id: str, @@ -289,12 +326,26 @@ async def create_draft_sector( expected_scheme_version_id=expected_scheme_version_id, ) - await validate_create_sector_uniqueness( + await validate_sector_patch_uniqueness( scheme_version_id=version.scheme_version_id, - sector_id=payload.sector_id, - element_id=payload.element_id, + sector_record_id="__create__", + new_sector_id=payload.sector_id, ) + existing = await list_scheme_version_sectors(version.scheme_version_id) + for row in existing: + if payload.element_id and row.element_id == payload.element_id: + from fastapi import HTTPException, status + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "duplicate_sector_element_id", + "message": "Sector element binding already exists in current draft version", + "element_id": payload.element_id, + "conflict_sector_record_id": row.sector_record_id, + }, + ) + row = await create_scheme_version_sector( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, @@ -338,12 +389,26 @@ async def create_draft_group( expected_scheme_version_id=expected_scheme_version_id, ) - await validate_create_group_uniqueness( + await validate_group_patch_uniqueness( scheme_version_id=version.scheme_version_id, - group_id=payload.group_id, - element_id=payload.element_id, + group_record_id="__create__", + new_group_id=payload.group_id, ) + existing = await list_scheme_version_groups(version.scheme_version_id) + for row in existing: + if payload.element_id and row.element_id == payload.element_id: + from fastapi import HTTPException, status + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "duplicate_group_element_id", + "message": "Group element binding already exists in current draft version", + "element_id": payload.element_id, + "conflict_group_record_id": row.group_record_id, + }, + ) + row = await create_scheme_version_group( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py index cf5a866..da8b6d8 100644 --- a/backend/app/api/routes/schemes.py +++ b/backend/app/api/routes/schemes.py @@ -18,7 +18,6 @@ from app.repositories.schemes import ( rollback_scheme_to_version, unpublish_scheme, ) -from app.schemas.publish_readiness import PublishExecutionResponse from app.schemas.scheme_registry import ( SchemeCurrentResponse, SchemeDetailResponse, @@ -29,6 +28,7 @@ from app.schemas.scheme_registry import ( SchemeRollbackResponse, ) from app.schemas.scheme_versions import ( + EnsureDraftResponse, SchemeVersionCreateResponse, SchemeVersionListItem, SchemeVersionListResponse, @@ -41,6 +41,19 @@ from app.services.scheme_validation import build_scheme_validation_report router = APIRouter() +def _build_stale_current_version_detail( + *, + expected_scheme_version_id: str, + actual_scheme_version_id: str, +) -> dict: + return { + "code": "stale_current_version", + "message": "Current scheme version changed. Reload scheme state before creating a new version.", + "expected_scheme_version_id": expected_scheme_version_id, + "actual_scheme_version_id": actual_scheme_version_id, + } + + @router.get(f"{settings.api_v1_prefix}/schemes", response_model=SchemeListResponse) async def get_schemes( limit: int = Query(default=50, ge=1, le=200), @@ -153,12 +166,10 @@ async def create_next_scheme_version_endpoint( and expected_current_scheme_version_id != current_version.scheme_version_id ): raise_conflict( - code="stale_current_version", - message="Current scheme version changed. Reload scheme state before creating a new version.", - details={ - "expected_scheme_version_id": expected_current_scheme_version_id, - "actual_scheme_version_id": current_version.scheme_version_id, - }, + _build_stale_current_version_detail( + expected_scheme_version_id=expected_current_scheme_version_id, + actual_scheme_version_id=current_version.scheme_version_id, + ) ) new_version = await create_next_scheme_version_from_current(scheme_id) @@ -197,6 +208,79 @@ async def create_next_scheme_version_endpoint( ) +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/ensure", response_model=EnsureDraftResponse) +async def ensure_draft_scheme_version( + scheme_id: str, + expected_current_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + scheme = await get_scheme_record_by_scheme_id(scheme_id) + current_version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + + if ( + expected_current_scheme_version_id + and expected_current_scheme_version_id != current_version.scheme_version_id + ): + raise_conflict( + _build_stale_current_version_detail( + expected_scheme_version_id=expected_current_scheme_version_id, + actual_scheme_version_id=current_version.scheme_version_id, + ) + ) + + if scheme.status == "draft" and current_version.status == "draft": + return EnsureDraftResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=current_version.scheme_version_id, + version_number=current_version.version_number, + status=current_version.status, + normalized_storage_path=current_version.normalized_storage_path, + created=False, + source_scheme_version_id=None, + ) + + new_version = await create_next_scheme_version_from_current(scheme_id) + + await clone_scheme_version_sectors( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + await clone_scheme_version_groups( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + await clone_scheme_version_seats( + source_scheme_version_id=current_version.scheme_version_id, + target_scheme_version_id=new_version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme_id, + event_type="scheme.version.created", + object_type="scheme_version", + object_ref=new_version.scheme_version_id, + details={ + "source_scheme_version_id": current_version.scheme_version_id, + "version_number": new_version.version_number, + "normalized_storage_path": new_version.normalized_storage_path, + "reason": "ensure_draft", + }, + ) + + return EnsureDraftResponse( + scheme_id=new_version.scheme_id, + scheme_version_id=new_version.scheme_version_id, + version_number=new_version.version_number, + status=new_version.status, + normalized_storage_path=new_version.normalized_storage_path, + created=True, + source_scheme_version_id=current_version.scheme_version_id, + ) + + @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation") async def get_publish_validation(scheme_id: str, role: str = Depends(require_api_key)): scheme = await get_scheme_record_by_scheme_id(scheme_id) @@ -215,20 +299,16 @@ async def get_publish_validation(scheme_id: str, role: str = Depends(require_api } -@router.post( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", - response_model=PublishExecutionResponse, -) +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish") async def publish_scheme_endpoint( scheme_id: str, expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - result = await publish_current_draft_scheme( + return await publish_current_draft_scheme( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) - return PublishExecutionResponse(**result) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index c2c98b7..f7beab5 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -56,6 +56,30 @@ class DraftStructureResponse(BaseModel): total_groups: int +class EditorContextResponse(BaseModel): + scheme_id: str + current_scheme_version_id: str + current_version_number: int + scheme_status: str + scheme_version_status: str + editor_available: bool + current_is_draft: bool + create_draft_available: bool + recommended_action: str + + +class DraftSummaryResponse(BaseModel): + scheme_id: str + scheme_version_id: str + status: str + total_seats: int + total_sectors: int + total_groups: int + validation_summary: dict + structure_diff_summary: dict + publish_readiness: dict + + class SeatPatchRequest(BaseModel): seat_id: str | None = Field(default=None, max_length=128) sector_id: str | None = Field(default=None, max_length=128) diff --git a/backend/app/schemas/scheme_versions.py b/backend/app/schemas/scheme_versions.py index ae15559..ec975ca 100644 --- a/backend/app/schemas/scheme_versions.py +++ b/backend/app/schemas/scheme_versions.py @@ -1,5 +1,3 @@ -from typing import List - from pydantic import BaseModel @@ -17,7 +15,7 @@ class SchemeVersionListItem(BaseModel): class SchemeVersionListResponse(BaseModel): - items: List[SchemeVersionListItem] + items: list[SchemeVersionListItem] total: int @@ -27,3 +25,13 @@ class SchemeVersionCreateResponse(BaseModel): version_number: int status: str normalized_storage_path: str + + +class EnsureDraftResponse(BaseModel): + scheme_id: str + scheme_version_id: str + version_number: int + status: str + normalized_storage_path: str + created: bool + source_scheme_version_id: str | None = None diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index 6f75c1e..e0100b7 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -47,6 +47,7 @@ - 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 ## app/api/routes/test_mode.py - GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} @@ -62,9 +63,12 @@ - POST /api/v1/schemes/{scheme_id}/draft/remap/apply ## app/api/routes/editor.py +- 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/compare-preview - 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} @@ -90,3 +94,4 @@ - This file is an operational route index, not a generated OpenAPI export. - Update this map in the same change set when adding, removing, renaming, or moving routes. - Query guards such as expected_current_scheme_version_id / expected_scheme_version_id are part of the operational contract for optimistic concurrency on mutable flows. +- Draft/editor routes may legally return 409 draft_not_editable when current version is already published and no editable draft exists yet. diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md index d472d76..c2e0c98 100644 --- a/backend/docs/smoke-regression.md +++ b/backend/docs/smoke-regression.md @@ -31,148 +31,91 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772" - 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 - ## 3. 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 - ## 4. 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 - -## 5. Pricing read model +## 5. Pricing read model / diagnostics - GET /api/v1/schemes/{scheme_id}/pricing -> 200 -- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 for priced seat -- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for priced and unpriced seat - 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 for priced seat +- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for priced and unpriced seat + +## 6. Editor entry workflow + +- GET /api/v1/schemes/{scheme_id}/editor/context -> 200 always +- if context.needs_new_draft=true -> POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200 +- after ensure -> GET /api/v1/schemes/{scheme_id}/editor/context -> 200 and editable=true Validate: -- pricing bundle contains categories and rules arrays -- effective seat price resolves according to domain priority -- test seat preview explains selectable / has_price state -- coverage endpoint is internally consistent -- explain endpoint returns matched_rule for priced seat and null for unpriced seat +- published current version does not break editor bootstrap +- ensure returns created_new_draft=true when current was published +- ensure returns created_new_draft=false when current was already draft -## 6. Draft editor read/write guards +## 7. Draft editor read model -- 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 /api/v1/schemes/{scheme_id}/draft/summary -> 200 when current version is draft +- GET /api/v1/schemes/{scheme_id}/draft/structure -> 200 when current version is draft +- GET /api/v1/schemes/{scheme_id}/draft/validation -> 200 when current version is draft +- GET /api/v1/schemes/{scheme_id}/draft/compare-preview -> 200 when current version is draft - 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 -- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} with unknown sector_id -> 422 -- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk with unknown group_id -> 422 -- POST /api/v1/schemes/{scheme_id}/draft/remap/preview with unknown target group -> 422 -- POST /api/v1/schemes/{scheme_id}/draft/sectors duplicate sector_id -> 422 -- POST /api/v1/schemes/{scheme_id}/draft/groups duplicate group_id -> 422 -Validate: -- stale expected_scheme_version_id returns 409 on guarded draft endpoints -- duplicate/reference failures return typed detail payloads -- successful read endpoints stay stable after failed mutations +## 8. Draft mutations -## 7. Draft publish preview / readiness +- POST /api/v1/schemes/{scheme_id}/draft/sectors -> 200 or typed 422 conflict +- POST /api/v1/schemes/{scheme_id}/draft/groups -> 200 or typed 422 conflict +- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 or typed 422 validation error +- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk -> 200 or typed 422 validation error +- PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200 +- PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200 +- POST /api/v1/schemes/{scheme_id}/draft/repair-references -> 200 +- POST /api/v1/schemes/{scheme_id}/draft/remap/preview -> 200 or typed 422 validation error +- POST /api/v1/schemes/{scheme_id}/draft/remap/apply -> 200 or typed 422 validation error + +## 9. Publish preview / readiness - GET /api/v1/schemes/{scheme_id}/publish/validation -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 when current version is draft - 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 -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness?expected_scheme_version_id={current_version_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness?expected_scheme_version_id=deadbeef... -> 409 -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 -- readiness returns validation_summary, pricing_coverage, snapshot, readiness flags +## 10. Publish lifecycle -## 8. Publish / version lifecycle +- POST /api/v1/schemes/{scheme_id}/publish -> 200 when draft is ready +- POST /api/v1/schemes/{scheme_id}/publish with stale expected_scheme_version_id -> 409 +- POST /api/v1/schemes/{scheme_id}/unpublish -> 200 +- POST /api/v1/schemes/{scheme_id}/rollback -> 200 -- POST /api/v1/schemes/{scheme_id}/versions?expected_current_scheme_version_id=deadbeef... -> 409 -- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id=deadbeef... -> 409 -- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id={current_version_id} -> 200 when environment is ready - -Validate: -- stale protection returns typed 409 payload -- successful publish returns scheme_id, scheme_version_id, status, current_version_number, published_at, pricing_snapshot, validation_summary -- after publish, current scheme status is published -- audit contains scheme.published event for the same scheme_version_id - -## 9. Admin / ops +## 11. 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 -Optional: -- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate?mode=passthrough -> 200 -- POST /api/v1/admin/display/backfill?mode=passthrough&limit=10&only_missing=true -> 200 - -Validate: -- audit endpoint does not report orphan files or missing files for DB rows in normal state -- validation report is readable and deterministic -- admin routes do not produce 500 for healthy scheme state - -## 10. Audit trail +## 12. Audit trail - GET /api/v1/schemes/{scheme_id}/audit -> 200 -Validate: -- recent publish preview / pricing / version events are present when corresponding operations were run -- audit total is non-negative -- event payloads stay JSON-serializable +## 13. Fail criteria -## 11. Fail criteria - -Regression is considered failed if any of the following happen: - -- health or db ping fails +Regression is considered failed if: - any stable read endpoint returns 500 -- passthrough display endpoint fails on known-good sample -- publish preview refresh or cached read returns 500 -- pricing bundle contract changes unexpectedly -- admin audit/cleanup endpoints fail on healthy environment -- artifact retention grows without bound for repeated preview refresh on same variant -- publish readiness says ready but guarded publish fails for non-stale reasons - -## 12. Operator note - -Run this checklist after: -- schema changes -- pricing schema/repository refactors -- artifact lifecycle changes -- display pipeline changes -- route reorganization -- startup/import/config changes -- publish lifecycle changes +- published current version cannot be converted to draft through ensure flow +- draft editor summary/read endpoints return inconsistent data +- publish-state mutation guard is bypassed