diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index 991aa7f..f2c654d 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -66,15 +66,22 @@ TASK_SUBTYPE_MAP = { ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys()) -"""P9.6 — type+subtype combos that may NOT be created via general create endpoints. - feature story → must come from propose accept - release maintenance → must come from controlled milestone/release flow +"""P9.6 / BE-PR-009 — type+subtype combos that may NOT be created via general + endpoints. All story/* subtypes are restricted; they must come from Proposal + Accept. maintenance/release must come from the milestone release flow. """ RESTRICTED_TYPE_SUBTYPES = { ("story", "feature"), + ("story", "improvement"), + ("story", "refactor"), + ("story", None), # story with no subtype is also blocked ("maintenance", "release"), } +# Convenience set: task types whose *entire* type is restricted regardless of subtype. +# Used for a fast-path check so we don't need to enumerate every subtype. +FULLY_RESTRICTED_TYPES = {"story"} + def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None, *, allow_restricted: bool = False): if task_type is None: @@ -84,13 +91,23 @@ def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None, allowed = TASK_SUBTYPE_MAP.get(task_type, set()) if task_subtype and task_subtype not in allowed: raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}') - # P9.6: block restricted combos unless explicitly allowed (e.g. propose accept, internal create) - if not allow_restricted and (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES: - raise HTTPException( - status_code=400, - detail=f"Cannot create {task_type}/{task_subtype} task via general create. " - f"Use the appropriate workflow (propose accept / milestone release setup)." - ) + # P9.6 / BE-PR-009: block restricted combos unless explicitly allowed + # (e.g. Proposal Accept, internal create) + if not allow_restricted: + # Fast-path: entire type is restricted (all story/* combos) + if task_type in FULLY_RESTRICTED_TYPES: + raise HTTPException( + status_code=400, + detail=f"Cannot create '{task_type}' tasks via general endpoints. " + f"Use the Proposal Accept workflow instead.", + ) + # Specific type+subtype combos (e.g. maintenance/release) + if (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES: + raise HTTPException( + status_code=400, + detail=f"Cannot create {task_type}/{task_subtype} task via general create. " + f"Use the appropriate workflow (Proposal Accept / milestone release setup).", + ) def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None): @@ -383,6 +400,16 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep detail="Only the current assignee or an admin can edit this task", ) + # BE-PR-009: prevent changing task_type to a restricted type via PATCH + new_task_type = update_data.get("task_type") + new_task_subtype = update_data.get("task_subtype", task.task_subtype) + if new_task_type is not None: + _validate_task_type_subtype(new_task_type, new_task_subtype) + elif "task_subtype" in update_data: + # subtype changed but type unchanged — validate the combo + current_type = task.task_type.value if hasattr(task.task_type, "value") else (task.task_type or "issue") + _validate_task_type_subtype(current_type, new_task_subtype) + # Legacy general permission check (covers project membership etc.) ensure_can_edit_task(db, current_user.id, task) if "status" in update_data: