diff --git a/backend/app/api/routes/editor.py b/backend/app/api/routes/editor.py index 71eadaf..3175b31 100644 --- a/backend/app/api/routes/editor.py +++ b/backend/app/api/routes/editor.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from app.core.config import settings from app.repositories.audit import create_audit_event @@ -152,9 +152,13 @@ async def get_draft_compare_preview( async def create_draft_sector( scheme_id: str, payload: CreateSectorRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) row = await create_scheme_version_sector( scheme_id=scheme.scheme_id, @@ -191,9 +195,13 @@ async def create_draft_sector( async def create_draft_group( scheme_id: str, payload: CreateGroupRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) row = await create_scheme_version_group( scheme_id=scheme.scheme_id, @@ -230,9 +238,13 @@ async def create_draft_group( async def delete_draft_sector( 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) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) await delete_scheme_version_sector_by_record_id( scheme_version_id=version.scheme_version_id, @@ -259,9 +271,13 @@ async def delete_draft_sector( async def delete_draft_group( 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) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) await delete_scheme_version_group_by_record_id( scheme_version_id=version.scheme_version_id, @@ -289,9 +305,13 @@ async def patch_draft_seat( scheme_id: str, seat_record_id: str, payload: SeatPatchRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) await validate_single_seat_patch_uniqueness( scheme_version_id=version.scheme_version_id, @@ -340,9 +360,13 @@ async def patch_draft_seat( async def bulk_patch_draft_seats( scheme_id: str, payload: BulkSeatPatchRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) items = [item.model_dump() for item in payload.items] await validate_bulk_seat_patch_uniqueness( @@ -389,9 +413,13 @@ async def patch_draft_sector( scheme_id: str, sector_record_id: str, payload: SectorPatchRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) await validate_sector_patch_uniqueness( scheme_version_id=version.scheme_version_id, @@ -421,7 +449,8 @@ async def patch_draft_sector( object_ref=sector_record_id, details={ "scheme_version_id": version.scheme_version_id, - "sector_id": payload.sector_id, + "old_sector_id": old_sector_id, + "new_sector_id": payload.sector_id, "name": payload.name, "cascaded_seats_count": cascaded_count, "repair_result": repair_result, @@ -442,9 +471,13 @@ async def patch_draft_group( scheme_id: str, group_record_id: str, payload: GroupPatchRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) await validate_group_patch_uniqueness( scheme_version_id=version.scheme_version_id, @@ -474,7 +507,8 @@ async def patch_draft_group( object_ref=group_record_id, details={ "scheme_version_id": version.scheme_version_id, - "group_id": payload.group_id, + "old_group_id": old_group_id, + "new_group_id": payload.group_id, "name": payload.name, "cascaded_seats_count": cascaded_count, "repair_result": repair_result, @@ -493,9 +527,14 @@ async def patch_draft_group( @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/repair-references", response_model=RepairReferencesResponse) async def repair_draft_references( 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) + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + result = await repair_structure_references( scheme_version_id=version.scheme_version_id, ) @@ -511,7 +550,7 @@ async def repair_draft_references( return RepairReferencesResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, - repaired_sector_refs_count=result["repaired_sector_refs_count"], - repaired_group_refs_count=result["repaired_group_refs_count"], - details=result["details"], + repaired_sector_refs_count=result.get("repaired_sector_refs_count", 0), + repaired_group_refs_count=result.get("repaired_group_refs_count", 0), + details=result, ) diff --git a/backend/app/api/routes/pricing.py b/backend/app/api/routes/pricing.py index 33d6989..60f9816 100644 --- a/backend/app/api/routes/pricing.py +++ b/backend/app/api/routes/pricing.py @@ -1,6 +1,6 @@ from decimal import Decimal -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from app.core.config import settings from app.repositories.audit import create_audit_event @@ -16,7 +16,6 @@ from app.repositories.pricing import ( ) from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot from app.repositories.schemes import get_scheme_record_by_scheme_id -from app.repositories.scheme_versions import get_current_scheme_version from app.schemas.pricing import ( PriceRuleCreateRequest, PriceRuleItem, @@ -27,18 +26,20 @@ from app.schemas.pricing import ( PricingCategoryUpdateRequest, ) from app.security.auth import require_api_key +from app.services.draft_guard import get_current_draft_context router = APIRouter() -async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None: - 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, +async def _refresh_current_draft_snapshot_if_possible( + *, + scheme_id: str, + expected_scheme_version_id: str | None = None, +) -> dict | None: + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, ) - if scheme.status != "draft" or version.status != "draft": - return None return await replace_scheme_version_pricing_snapshot( scheme_id=scheme.scheme_id, @@ -82,26 +83,41 @@ async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key async def create_pricing_category_endpoint( scheme_id: str, payload: PricingCategoryCreateRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - pricing_category_id = await create_pricing_category( + scheme, version = await get_current_draft_context( scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + + pricing_category_id = await create_pricing_category( + scheme_id=scheme.scheme_id, name=payload.name, code=payload.code, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + snapshot = await _refresh_current_draft_snapshot_if_possible( + scheme_id=scheme.scheme_id, + expected_scheme_version_id=version.scheme_version_id, + ) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.category.created", object_type="pricing_category", object_ref=pricing_category_id, - details={"name": payload.name, "code": payload.code, "snapshot": snapshot}, + details={ + "name": payload.name, + "code": payload.code, + "scheme_version_id": version.scheme_version_id, + "snapshot": snapshot, + }, ) return { "pricing_category_id": pricing_category_id, - "scheme_id": scheme_id, + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, "name": payload.name, "code": payload.code, } @@ -112,27 +128,42 @@ async def update_pricing_category_endpoint( scheme_id: str, pricing_category_id: str, payload: PricingCategoryUpdateRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - row = await update_pricing_category( + scheme, version = await get_current_draft_context( scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + + row = await update_pricing_category( + scheme_id=scheme.scheme_id, pricing_category_id=pricing_category_id, name=payload.name, code=payload.code, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + snapshot = await _refresh_current_draft_snapshot_if_possible( + scheme_id=scheme.scheme_id, + expected_scheme_version_id=version.scheme_version_id, + ) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.category.updated", object_type="pricing_category", object_ref=pricing_category_id, - details={"name": payload.name, "code": payload.code, "snapshot": snapshot}, + details={ + "name": payload.name, + "code": payload.code, + "scheme_version_id": version.scheme_version_id, + "snapshot": snapshot, + }, ) return { "pricing_category_id": row.pricing_category_id, "scheme_id": row.scheme_id, + "scheme_version_id": version.scheme_version_id, "name": row.name, "code": row.code, } @@ -142,31 +173,53 @@ async def update_pricing_category_endpoint( async def delete_pricing_category_endpoint( scheme_id: str, pricing_category_id: str, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - await delete_pricing_category( + scheme, version = await get_current_draft_context( scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + + await delete_pricing_category( + scheme_id=scheme.scheme_id, pricing_category_id=pricing_category_id, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + snapshot = await _refresh_current_draft_snapshot_if_possible( + scheme_id=scheme.scheme_id, + expected_scheme_version_id=version.scheme_version_id, + ) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.category.deleted", object_type="pricing_category", object_ref=pricing_category_id, - details={"snapshot": snapshot}, + details={ + "scheme_version_id": version.scheme_version_id, + "snapshot": snapshot, + }, ) - return {"deleted": True, "pricing_category_id": pricing_category_id} + return { + "deleted": True, + "pricing_category_id": pricing_category_id, + "scheme_version_id": version.scheme_version_id, + } @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules") async def create_price_rule_endpoint( scheme_id: str, payload: PriceRuleCreateRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + try: amount = Decimal(payload.amount) except Exception: @@ -176,17 +229,20 @@ async def create_price_rule_endpoint( ) price_rule_id = await create_price_rule( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, pricing_category_id=payload.pricing_category_id, target_type=payload.target_type, target_ref=payload.target_ref, amount=amount, currency=payload.currency, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + snapshot = await _refresh_current_draft_snapshot_if_possible( + scheme_id=scheme.scheme_id, + expected_scheme_version_id=version.scheme_version_id, + ) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.rule.created", object_type="price_rule", object_ref=price_rule_id, @@ -196,13 +252,15 @@ async def create_price_rule_endpoint( "target_ref": payload.target_ref, "amount": payload.amount, "currency": payload.currency, + "scheme_version_id": version.scheme_version_id, "snapshot": snapshot, }, ) return { "price_rule_id": price_rule_id, - "scheme_id": scheme_id, + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, "pricing_category_id": payload.pricing_category_id, "target_type": payload.target_type, "target_ref": payload.target_ref, @@ -216,8 +274,14 @@ async def update_price_rule_endpoint( scheme_id: str, price_rule_id: str, payload: PriceRuleUpdateRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + try: amount = Decimal(payload.amount) except Exception: @@ -227,7 +291,7 @@ async def update_price_rule_endpoint( ) row = await update_price_rule( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, price_rule_id=price_rule_id, pricing_category_id=payload.pricing_category_id, target_type=payload.target_type, @@ -235,10 +299,13 @@ async def update_price_rule_endpoint( amount=amount, currency=payload.currency, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + snapshot = await _refresh_current_draft_snapshot_if_possible( + scheme_id=scheme.scheme_id, + expected_scheme_version_id=version.scheme_version_id, + ) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.rule.updated", object_type="price_rule", object_ref=price_rule_id, @@ -248,6 +315,7 @@ async def update_price_rule_endpoint( "target_ref": payload.target_ref, "amount": payload.amount, "currency": payload.currency, + "scheme_version_id": version.scheme_version_id, "snapshot": snapshot, }, ) @@ -255,6 +323,7 @@ async def update_price_rule_endpoint( return { "price_rule_id": row.price_rule_id, "scheme_id": row.scheme_id, + "scheme_version_id": version.scheme_version_id, "pricing_category_id": row.pricing_category_id, "target_type": row.target_type, "target_ref": row.target_ref, @@ -267,20 +336,36 @@ async def update_price_rule_endpoint( async def delete_price_rule_endpoint( scheme_id: str, price_rule_id: str, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - await delete_price_rule( + scheme, version = await get_current_draft_context( scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + + await delete_price_rule( + scheme_id=scheme.scheme_id, price_rule_id=price_rule_id, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + snapshot = await _refresh_current_draft_snapshot_if_possible( + scheme_id=scheme.scheme_id, + expected_scheme_version_id=version.scheme_version_id, + ) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.rule.deleted", object_type="price_rule", object_ref=price_rule_id, - details={"snapshot": snapshot}, + details={ + "scheme_version_id": version.scheme_version_id, + "snapshot": snapshot, + }, ) - return {"deleted": True, "price_rule_id": price_rule_id} + return { + "deleted": True, + "price_rule_id": price_rule_id, + "scheme_version_id": version.scheme_version_id, + } diff --git a/backend/app/api/routes/publish.py b/backend/app/api/routes/publish.py index 7976e6a..ee8376c 100644 --- a/backend/app/api/routes/publish.py +++ b/backend/app/api/routes/publish.py @@ -22,9 +22,13 @@ router = APIRouter() @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/pricing/snapshot") async def create_draft_pricing_snapshot( 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) + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) result = await replace_scheme_version_pricing_snapshot( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, @@ -50,9 +54,13 @@ async def get_publish_preview( scheme_id: str, baseline_scheme_version_id: str | None = Query(default=None), refresh: bool = Query(default=False), + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) bundle = await get_or_build_publish_preview_bundle( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, @@ -74,9 +82,13 @@ async def get_publish_preview( async def preview_draft_remap( scheme_id: str, payload: RemapPreviewRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) items = await preview_remap( scheme_version_id=version.scheme_version_id, seat_record_ids=payload.seat_record_ids, @@ -98,9 +110,13 @@ async def preview_draft_remap( async def apply_draft_remap( scheme_id: str, payload: RemapApplyRequest, + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - scheme, version = await get_current_draft_context(scheme_id) + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) items = await apply_remap( scheme_version_id=version.scheme_version_id, seat_record_ids=payload.seat_record_ids, diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py index c8fe25c..466169d 100644 --- a/backend/app/api/routes/schemes.py +++ b/backend/app/api/routes/schemes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from app.core.config import settings from app.repositories.audit import create_audit_event @@ -137,6 +137,7 @@ async def get_scheme_versions( @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse) async def create_next_scheme_version_endpoint( scheme_id: str, + expected_current_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): current_scheme = await get_scheme_record_by_scheme_id(scheme_id) @@ -145,6 +146,20 @@ async def create_next_scheme_version_endpoint( current_version_number=current_scheme.current_version_number, ) + if ( + expected_current_scheme_version_id is not None + and expected_current_scheme_version_id != current_version.scheme_version_id + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "stale_current_version", + "message": "Current scheme version changed. Reload scheme state before creating a new version.", + "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) await clone_scheme_version_sectors( @@ -200,8 +215,15 @@ async def get_publish_validation(scheme_id: str, role: str = Depends(require_api @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish") -async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): - return await publish_current_draft_scheme(scheme_id=scheme_id) +async def publish_scheme_endpoint( + scheme_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + return await publish_current_draft_scheme( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) diff --git a/backend/app/services/draft_guard.py b/backend/app/services/draft_guard.py index 29bded0..1ed89c4 100644 --- a/backend/app/services/draft_guard.py +++ b/backend/app/services/draft_guard.py @@ -4,7 +4,30 @@ from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.schemes import get_scheme_record_by_scheme_id -async def get_current_draft_context(scheme_id: str): +def ensure_expected_scheme_version_id( + *, + actual_scheme_version_id: str, + expected_scheme_version_id: str | None, +) -> None: + if expected_scheme_version_id is None: + return + + if expected_scheme_version_id != actual_scheme_version_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "stale_draft_version", + "message": "Draft scheme version is stale. Reload current draft state before applying mutation.", + "expected_scheme_version_id": expected_scheme_version_id, + "actual_scheme_version_id": actual_scheme_version_id, + }, + ) + + +async def get_current_draft_context( + scheme_id: str, + expected_scheme_version_id: str | None = None, +): scheme = await get_scheme_record_by_scheme_id(scheme_id) version = await get_current_scheme_version( scheme_id=scheme.scheme_id, @@ -17,4 +40,9 @@ async def get_current_draft_context(scheme_id: str): detail="Current scheme version is not editable because it is not in draft state", ) + ensure_expected_scheme_version_id( + actual_scheme_version_id=version.scheme_version_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + return scheme, version diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 64e70c2..b11eefb 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -2,27 +2,21 @@ from fastapi import HTTPException, status from app.repositories.audit import create_audit_event from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot -from app.repositories.scheme_versions import get_current_scheme_version -from app.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme +from app.services.draft_guard import get_current_draft_context +from app.repositories.schemes import publish_scheme from app.services.scheme_validation import build_scheme_validation_report async def publish_current_draft_scheme( *, scheme_id: str, + expected_scheme_version_id: str | None = None, ) -> dict: - 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, + scheme, version = await get_current_draft_context( + scheme_id=scheme_id, + expected_scheme_version_id=expected_scheme_version_id, ) - if scheme.status != "draft" or version.status != "draft": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Current scheme version is not publishable because it is not in draft state", - ) - validation = await build_scheme_validation_report( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index acb640f..ff04ca4 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -20,6 +20,7 @@ - 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 - POST /api/v1/schemes/{scheme_id}/publish - POST /api/v1/schemes/{scheme_id}/unpublish - POST /api/v1/schemes/{scheme_id}/rollback @@ -47,3 +48,22 @@ ## app/api/routes/audit.py - GET /api/v1/schemes/{scheme_id}/audit + +## app/api/routes/publish.py +- 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 + +## app/api/routes/admin.py +- 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 + +## Notes +- 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. +- Editor routes are maintained separately and should be synced from current source before relying on this file as a full route inventory. diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md new file mode 100644 index 0000000..8128df7 --- /dev/null +++ b/backend/docs/smoke-regression.md @@ -0,0 +1,136 @@ +# 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 +- test scheme exists + +## Environment + +Use these variables in shell: + +export API_URL="http://127.0.0.1:9020" +export API_KEY="admin-local-dev-key" +export SCHEME_ID="82086336d385427f9d56244f9e1dd772" + +## 1. Health / system + +- GET /healthz -> 200 +- GET /api/v1/ping -> 200 +- GET /api/v1/db/ping -> 200 +- GET /api/v1/manifest -> 200 + +## 2. 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 + +## 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 + +- 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 known seat + +Validate: +- pricing bundle contains categories and rules arrays +- effective seat price resolves according to domain priority +- test seat preview explains selectable / has_price state + +## 6. Draft publish preview + +- GET /api/v1/schemes/{scheme_id}/publish/validation -> 200 +- 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 + +## 7. 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 + +## 8. 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 + +## 9. 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 +- 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 + +## 10. Operator note + +Run this checklist after: +- schema changes +- pricing schema/repository refactors +- artifact lifecycle changes +- display pipeline changes +- route reorganization +- startup/import/config changes