diff --git a/backend/app/api/work.py b/backend/app/api/work.py index 4b54a53..8f66cae 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -13,7 +13,7 @@ from app.integrations.notify import NotifyContext, notify_openclaw from app.integrations.openclaw import OpenClawClient from app.models.org import Employee from app.models.work import Task, TaskComment -from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate +from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate logger = logging.getLogger("app.work") @@ -149,6 +149,97 @@ def dispatch_task( return {"ok": True} +def _require_reviewer_comment(body: str | None) -> str: + if body is None or not body.strip(): + raise HTTPException(status_code=400, detail="Reviewer must provide a comment for audit") + return body.strip() + + +@router.post("/tasks/{task_id}/review", response_model=Task) +def review_task( + task_id: int, + payload: TaskReviewDecision, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + """Reviewer approves or requests changes. + + - Approve => status=done + - Changes => status=in_progress + + Always writes a TaskComment by the reviewer for audit. + """ + + task = session.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + if task.reviewer_employee_id is None: + raise HTTPException(status_code=400, detail="Task has no reviewer") + + if actor_employee_id != task.reviewer_employee_id: + raise HTTPException(status_code=403, detail="Only the reviewer can approve/request changes") + + decision = (payload.decision or "").strip().lower() + if decision not in {"approve", "changes"}: + raise HTTPException(status_code=400, detail="Invalid decision") + + comment_body = _require_reviewer_comment(payload.comment_body) + + new_status = "done" if decision == "approve" else "in_progress" + + before_status = task.status + task.status = new_status + task.updated_at = datetime.utcnow() + session.add(task) + + c = TaskComment(task_id=task.id, author_employee_id=actor_employee_id, body=comment_body) + session.add(c) + + try: + session.flush() + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="task", + entity_id=task.id, + verb="reviewed", + payload={"decision": decision, "status": new_status}, + ) + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(status_code=409, detail="Review action violates constraints") + + session.refresh(task) + session.refresh(c) + + # Notify assignee (comment.created will exclude author) + background.add_task( + notify_openclaw, + session, + NotifyContext( + event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c + ), + ) + + # Notify reviewer/PMs about status change + if before_status != task.status: + background.add_task( + notify_openclaw, + session, + NotifyContext( + event="status.changed", + actor_employee_id=actor_employee_id, + task=task, + changed_fields={"status": {"from": before_status, "to": task.status}}, + ), + ) + + return Task.model_validate(task) + + @router.patch("/tasks/{task_id}", response_model=Task) def update_task( task_id: int, @@ -174,6 +265,20 @@ def update_task( if "status" in data and data["status"] not in ALLOWED_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") + # Enforce review workflow: agent assignees cannot mark tasks done directly. + if data.get("status") == "done": + assignee = ( + session.get(Employee, task.assignee_employee_id) if task.assignee_employee_id else None + ) + if assignee is not None and assignee.employee_type == "agent": + if actor_employee_id == task.assignee_employee_id: + raise HTTPException( + status_code=403, + detail="Assignee agents cannot mark tasks done; set status=review for manager approval", + ) + if task.reviewer_employee_id is not None and actor_employee_id != task.reviewer_employee_id: + raise HTTPException(status_code=403, detail="Only the reviewer can mark a task done") + # If a task is sent to review and no reviewer is set, default reviewer to assignee's manager. if ( data.get("status") in {"review", "ready_for_review"} diff --git a/backend/app/schemas/work.py b/backend/app/schemas/work.py index 9d067d7..92b460c 100644 --- a/backend/app/schemas/work.py +++ b/backend/app/schemas/work.py @@ -26,3 +26,8 @@ class TaskCommentCreate(SQLModel): author_employee_id: int | None = None reply_to_comment_id: int | None = None body: str + + +class TaskReviewDecision(SQLModel): + decision: str # approve | changes + comment_body: str diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index f837637..3dbfd2a 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -32,6 +32,7 @@ export * from "./task"; export * from "./taskComment"; export * from "./taskCommentCreate"; export * from "./taskCreate"; +export * from "./taskReviewDecision"; export * from "./taskUpdate"; export * from "./team"; export * from "./teamCreate"; diff --git a/frontend/src/api/generated/model/taskReviewDecision.ts b/frontend/src/api/generated/model/taskReviewDecision.ts new file mode 100644 index 0000000..ed64c20 --- /dev/null +++ b/frontend/src/api/generated/model/taskReviewDecision.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * OpenClaw Agency API + * OpenAPI spec version: 0.3.0 + */ + +export interface TaskReviewDecision { + decision: string; + comment_body: string; +} diff --git a/frontend/src/api/generated/work/work.ts b/frontend/src/api/generated/work/work.ts index 505d893..64fb02c 100644 --- a/frontend/src/api/generated/work/work.ts +++ b/frontend/src/api/generated/work/work.ts @@ -28,6 +28,7 @@ import type { TaskComment, TaskCommentCreate, TaskCreate, + TaskReviewDecision, TaskUpdate, } from ".././model"; @@ -467,6 +468,130 @@ export const useDispatchTaskTasksTaskIdDispatchPost = < queryClient, ); }; +/** + * Reviewer approves or requests changes. + +- Approve => status=done +- Changes => status=in_progress + +Always writes a TaskComment by the reviewer for audit. + * @summary Review Task + */ +export type reviewTaskTasksTaskIdReviewPostResponse200 = { + data: Task; + status: 200; +}; + +export type reviewTaskTasksTaskIdReviewPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type reviewTaskTasksTaskIdReviewPostResponseSuccess = + reviewTaskTasksTaskIdReviewPostResponse200 & { + headers: Headers; + }; +export type reviewTaskTasksTaskIdReviewPostResponseError = + reviewTaskTasksTaskIdReviewPostResponse422 & { + headers: Headers; + }; + +export type reviewTaskTasksTaskIdReviewPostResponse = + | reviewTaskTasksTaskIdReviewPostResponseSuccess + | reviewTaskTasksTaskIdReviewPostResponseError; + +export const getReviewTaskTasksTaskIdReviewPostUrl = (taskId: number) => { + return `/tasks/${taskId}/review`; +}; + +export const reviewTaskTasksTaskIdReviewPost = async ( + taskId: number, + taskReviewDecision: TaskReviewDecision, + options?: RequestInit, +): Promise => { + return customFetch( + getReviewTaskTasksTaskIdReviewPostUrl(taskId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(taskReviewDecision), + }, + ); +}; + +export const getReviewTaskTasksTaskIdReviewPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext +> => { + const mutationKey = ["reviewTaskTasksTaskIdReviewPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { taskId: number; data: TaskReviewDecision } + > = (props) => { + const { taskId, data } = props ?? {}; + + return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable< + Awaited> +>; +export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision; +export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError; + +/** + * @summary Review Task + */ +export const useReviewTaskTasksTaskIdReviewPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext +> => { + return useMutation( + getReviewTaskTasksTaskIdReviewPostMutationOptions(options), + queryClient, + ); +}; /** * @summary Update Task */