Merge master into jarvis/human-id-1

This commit is contained in:
Jarvis
2026-02-02 16:58:39 +00:00
55 changed files with 2230 additions and 197 deletions

28
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
language_version: python3
files: ^backend/.*\.py$
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
files: ^backend/.*\.py$
- repo: https://github.com/PyCQA/flake8
rev: 7.1.1
hooks:
- id: flake8
files: ^backend/.*\.py$
args: [--config=backend/.flake8]

View File

@@ -14,7 +14,7 @@ No auth (yet). The goal is simple visibility: everyone can see what exists and w
Uses local Postgres:
- user: `postgres`
- password: `netbox`
- password: `REDACTED`
- db: `openclaw_agency`
## Environment

10
backend/.flake8 Normal file
View File

@@ -0,0 +1,10 @@
[flake8]
max-line-length = 100
extend-ignore = E203, W503, E501
exclude =
.venv,
backend/.venv,
alembic,
backend/alembic,
**/__pycache__,
**/*.pyc

View File

@@ -86,7 +86,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url =
sqlalchemy.url =
[post_write_hooks]

View File

@@ -1 +1 @@
Generic single-database configuration.
Generic single-database configuration.

View File

@@ -2,14 +2,14 @@ from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from sqlmodel import SQLModel
from alembic import context
# Import models to register tables in metadata
from app import models # noqa: F401
from app.core.config import settings
config = context.config

View File

@@ -8,9 +8,9 @@ Create Date: 2026-02-02
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "3f2c1b9c8e12"

View File

@@ -10,6 +10,7 @@ from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.

View File

@@ -13,7 +13,9 @@ router = APIRouter(prefix="/activities", tags=["activities"])
@router.get("")
def list_activities(limit: int = 50, session: Session = Depends(get_session)):
items = session.exec(select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))).all()
items = session.exec(
select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))
).all()
out = []
for a in items:
out.append(

View File

@@ -5,21 +5,42 @@ from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api.utils import get_actor_employee_id, log_activity
from app.core.urls import public_api_base_url
from app.db.session import get_session
from app.integrations.openclaw import OpenClawClient
from app.models.org import Department, Team, Employee
from app.models.org import Department, Employee, Team
from app.schemas.org import (
DepartmentCreate,
DepartmentUpdate,
TeamCreate,
TeamUpdate,
EmployeeCreate,
EmployeeUpdate,
TeamCreate,
TeamUpdate,
)
router = APIRouter(tags=["org"])
def _enforce_employee_create_policy(
session: Session, *, actor_employee_id: int, target_employee_type: str
) -> None:
"""Enforce: agents can only create/provision agents; humans can create humans + agents."""
actor = session.get(Employee, actor_employee_id)
if actor is None:
# Actor header is required; if it points to nothing, treat as invalid.
raise HTTPException(status_code=400, detail="Actor employee not found")
target = (target_employee_type or "").lower()
actor_type = (actor.employee_type or "").lower()
if actor_type == "agent" and target != "agent":
raise HTTPException(
status_code=403,
detail="Agent employees may only create/provision agent employees",
)
def _default_agent_prompt(emp: Employee) -> str:
"""Generate a conservative default prompt for a newly-created agent employee.
@@ -34,9 +55,15 @@ def _default_agent_prompt(emp: Employee) -> str:
f"Your employee_id is {emp.id}.\n"
f"Title: {title}. Department id: {dept}.\n\n"
"Mission Control API access (no UI):\n"
"- Base URL: http://127.0.0.1:8000 (if running locally) OR http://<dev-machine-ip>:8000\n"
"- Auth: none. REQUIRED header on write operations: X-Actor-Employee-Id: <your employee_id>\n"
f" For you: X-Actor-Employee-Id: {emp.id}\n\n"
f"- Base URL: {public_api_base_url()}\n"
"- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: <your_employee_id>\n"
f" Example for you: X-Actor-Employee-Id: {emp.id}\n\n"
"How to execute writes from an OpenClaw agent (IMPORTANT):\n"
"- Use the exec tool to run curl against the Base URL above.\n"
"- Example: start a task\n"
" curl -sS -X PATCH $BASE/tasks/<TASK_ID> -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"status\":\"in_progress\"}'\n"
"- Example: add a progress comment\n"
" curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"task_id\":<TASK_ID>,\"body\":\"...\"}'\n\n"
"Common endpoints (JSON):\n"
"- GET /tasks, POST /tasks\n"
"- GET /task-comments, POST /task-comments\n"
@@ -44,7 +71,11 @@ def _default_agent_prompt(emp: Employee) -> str:
"- OpenAPI schema: GET /openapi.json\n\n"
"Rules:\n"
"- Use the Mission Control API only (no UI).\n"
"- When notified about tasks/comments, respond with concise, actionable updates.\n"
"- You are responsible for driving assigned work to completion.\n"
"- For every task you own: (1) read it, (2) plan next steps, (3) post progress comments, (4) update status as it moves (backlog/ready/in_progress/review/done/blocked).\n"
"- Always leave an audit trail: add a comment whenever you start work, whenever you learn something important, and whenever you change status.\n"
"- If blocked, set status=blocked and comment what you need (missing access, unclear requirements, etc.).\n"
"- When notified about tasks/comments, respond with concise, actionable updates and immediately sync the task state in Mission Control.\n"
"- Do not invent facts; ask for missing context.\n"
)
@@ -56,6 +87,11 @@ def _maybe_auto_provision_agent(session: Session, *, emp: Employee, actor_employ
we leave the employee as-is (openclaw_session_key stays null).
"""
# Enforce: agent actors may only provision agents (humans can provision agents).
_enforce_employee_create_policy(
session, actor_employee_id=actor_employee_id, target_employee_type=emp.employee_type
)
if emp.employee_type != "agent":
return
if emp.status != "active":
@@ -159,7 +195,11 @@ def create_team(
entity_type="team",
entity_id=team.id,
verb="created",
payload={"name": team.name, "department_id": team.department_id, "lead_employee_id": team.lead_employee_id},
payload={
"name": team.name,
"department_id": team.department_id,
"lead_employee_id": team.lead_employee_id,
},
)
session.commit()
except IntegrityError:
@@ -188,7 +228,14 @@ def update_team(
session.add(team)
try:
session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="team", entity_id=team.id, verb="updated", payload=data)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="team",
entity_id=team.id,
verb="updated",
payload=data,
)
session.commit()
except IntegrityError:
session.rollback()
@@ -226,7 +273,9 @@ def create_department(
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Department already exists or violates constraints")
raise HTTPException(
status_code=409, detail="Department already exists or violates constraints"
)
session.refresh(dept)
return dept
@@ -250,7 +299,14 @@ def update_department(
session.add(dept)
session.commit()
session.refresh(dept)
log_activity(session, actor_employee_id=actor_employee_id, entity_type="department", entity_id=dept.id, verb="updated", payload=data)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="department",
entity_id=dept.id,
verb="updated",
payload=data,
)
session.commit()
return dept
@@ -266,6 +322,10 @@ def create_employee(
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
_enforce_employee_create_policy(
session, actor_employee_id=actor_employee_id, target_employee_type=payload.employee_type
)
emp = Employee(**payload.model_dump())
session.add(emp)
@@ -310,7 +370,14 @@ def update_employee(
session.add(emp)
try:
session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="updated", payload=data)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="employee",
entity_id=emp.id,
verb="updated",
payload=data,
)
session.commit()
except IntegrityError:
session.rollback()
@@ -357,7 +424,10 @@ def deprovision_employee_agent(
try:
client.tools_invoke(
"sessions_send",
{"sessionKey": emp.openclaw_session_key, "message": "You are being deprovisioned. Stop all work and ignore future messages."},
{
"sessionKey": emp.openclaw_session_key,
"message": "You are being deprovisioned. Stop all work and ignore future messages.",
},
timeout_s=5.0,
)
except Exception:

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api.utils import log_activity, get_actor_employee_id
from app.api.utils import get_actor_employee_id, log_activity
from app.db.session import get_session
from app.models.projects import Project, ProjectMember
from app.schemas.projects import ProjectCreate, ProjectUpdate
@@ -45,15 +45,21 @@ def create_project(
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Project already exists or violates constraints")
raise HTTPException(
status_code=409, detail="Project already exists or violates constraints"
)
session.refresh(proj)
return proj
@router.patch("/{project_id}", response_model=Project)
def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
def update_project(
project_id: int,
payload: ProjectUpdate,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
proj = session.get(Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
@@ -65,7 +71,14 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
session.add(proj)
session.commit()
session.refresh(proj)
log_activity(session, actor_employee_id=actor_employee_id, entity_type="project", entity_id=proj.id, verb="updated", payload=data)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project",
entity_id=proj.id,
verb="updated",
payload=data,
)
session.commit()
return proj
@@ -73,16 +86,29 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
@router.get("/{project_id}/members", response_model=list[ProjectMember])
def list_project_members(project_id: int, session: Session = Depends(get_session)):
return session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).order_by(ProjectMember.id.asc())
select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.order_by(ProjectMember.id.asc())
).all()
@router.post("/{project_id}/members", response_model=ProjectMember)
def add_project_member(project_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
existing = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id)).first()
def add_project_member(
project_id: int,
payload: ProjectMember,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
existing = session.exec(
select(ProjectMember).where(
ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id
)
).first()
if existing:
raise HTTPException(status_code=409, detail="Member already added")
member = ProjectMember(project_id=project_id, employee_id=payload.employee_id, role=payload.role)
member = ProjectMember(
project_id=project_id, employee_id=payload.employee_id, role=payload.role
)
session.add(member)
session.commit()
session.refresh(member)
@@ -99,7 +125,12 @@ def add_project_member(project_id: int, payload: ProjectMember, session: Session
@router.delete("/{project_id}/members/{member_id}")
def remove_project_member(project_id: int, member_id: int, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
def remove_project_member(
project_id: int,
member_id: int,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
member = session.get(ProjectMember, member_id)
if not member or member.project_id != project_id:
raise HTTPException(status_code=404, detail="Project member not found")
@@ -118,7 +149,13 @@ def remove_project_member(project_id: int, member_id: int, session: Session = De
@router.patch("/{project_id}/members/{member_id}", response_model=ProjectMember)
def update_project_member(project_id: int, member_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
def update_project_member(
project_id: int,
member_id: int,
payload: ProjectMember,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
member = session.get(ProjectMember, member_id)
if not member or member.project_id != project_id:
raise HTTPException(status_code=404, detail="Project member not found")

View File

@@ -1,23 +1,49 @@
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlmodel import Session, select
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api.utils import log_activity, get_actor_employee_id
from app.api.utils import get_actor_employee_id, log_activity
from app.db.session import get_session
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.integrations.notify import NotifyContext, notify_openclaw
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
logger = logging.getLogger("app.work")
router = APIRouter(tags=["work"])
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None:
"""Enforce that only provisioned agents can be assigned tasks.
Humans can be assigned regardless.
Agents must be active, notify_enabled, and have openclaw_session_key.
"""
emp = session.get(Employee, assignee_employee_id)
if emp is None:
raise HTTPException(status_code=400, detail="Assignee employee not found")
if emp.employee_type == "agent":
if emp.status != "active":
raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent")
if not emp.notify_enabled:
raise HTTPException(
status_code=400, detail="Cannot assign task to agent with notifications disabled"
)
if not emp.openclaw_session_key:
raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent")
@router.get("/tasks", response_model=list[Task])
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
stmt = select(Task).order_by(Task.id.asc())
@@ -27,15 +53,27 @@ def list_tasks(project_id: int | None = None, session: Session = Depends(get_ses
@router.post("/tasks", response_model=Task)
def create_task(payload: TaskCreate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
def create_task(
payload: TaskCreate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
if payload.created_by_employee_id is None:
payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id})
payload = TaskCreate(
**{**payload.model_dump(), "created_by_employee_id": actor_employee_id}
)
if payload.assignee_employee_id is not None:
_validate_task_assignee(session, payload.assignee_employee_id)
# Default reviewer to the manager of the assignee (if not explicitly provided).
if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None:
assignee = session.get(Employee, payload.assignee_employee_id)
if assignee is not None and assignee.manager_id is not None:
payload = TaskCreate(**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id})
payload = TaskCreate(
**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}
)
task = Task(**payload.model_dump())
if task.status not in ALLOWED_STATUSES:
@@ -59,23 +97,197 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi
raise HTTPException(status_code=409, detail="Task create violates constraints")
session.refresh(task)
background.add_task(notify_openclaw, session, NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task=task))
background.add_task(
notify_openclaw,
NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task_id=task.id),
)
# Explicitly return a serializable payload (guards against empty {} responses)
return Task.model_validate(task)
@router.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
@router.post("/tasks/{task_id}/dispatch")
def dispatch_task(
task_id: int,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
before = {"assignee_employee_id": task.assignee_employee_id, "reviewer_employee_id": task.reviewer_employee_id, "status": task.status}
logger.info(
"dispatch_task: loaded",
extra={
"task_id": getattr(task, "id", None),
"assignee_employee_id": getattr(task, "assignee_employee_id", None),
},
)
if task.assignee_employee_id is None:
raise HTTPException(status_code=400, detail="Task has no assignee")
_validate_task_assignee(session, task.assignee_employee_id)
client = OpenClawClient.from_env()
if client is None:
logger.warning("dispatch_task: missing OpenClaw env")
raise HTTPException(
status_code=503,
detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)",
)
# Best-effort: enqueue an agent dispatch. This does not mutate the task.
background.add_task(
notify_openclaw,
session,
NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task_id=task.id),
)
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,
NotifyContext(
event="status.changed",
actor_employee_id=actor_employee_id,
task_id=task.id,
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,
payload: TaskUpdate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
before = {
"assignee_employee_id": task.assignee_employee_id,
"reviewer_employee_id": task.reviewer_employee_id,
"status": task.status,
}
data = payload.model_dump(exclude_unset=True)
if "assignee_employee_id" in data and data["assignee_employee_id"] is not None:
_validate_task_assignee(session, data["assignee_employee_id"])
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"}
and data.get("reviewer_employee_id") is None
):
assignee_id = data.get("assignee_employee_id", task.assignee_employee_id)
if assignee_id is not None:
assignee = session.get(Employee, assignee_id)
if assignee is not None and assignee.manager_id is not None:
data["reviewer_employee_id"] = assignee.manager_id
for k, v in data.items():
setattr(task, k, v)
task.updated_at = datetime.utcnow()
@@ -83,7 +295,14 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks,
try:
session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task.id, verb="updated", payload=data)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task.id,
verb="updated",
payload=data,
)
session.commit()
except IntegrityError:
session.rollback()
@@ -94,19 +313,51 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks,
# notify based on meaningful changes
changed = {}
if before.get("assignee_employee_id") != task.assignee_employee_id:
changed["assignee_employee_id"] = {"from": before.get("assignee_employee_id"), "to": task.assignee_employee_id}
background.add_task(notify_openclaw, session, NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task, changed_fields=changed))
changed["assignee_employee_id"] = {
"from": before.get("assignee_employee_id"),
"to": task.assignee_employee_id,
}
background.add_task(
notify_openclaw,
NotifyContext(
event="task.assigned",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields=changed,
),
)
if before.get("status") != task.status:
changed["status"] = {"from": before.get("status"), "to": task.status}
background.add_task(notify_openclaw, session, NotifyContext(event="status.changed", actor_employee_id=actor_employee_id, task=task, changed_fields=changed))
background.add_task(
notify_openclaw,
NotifyContext(
event="status.changed",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields=changed,
),
)
if not changed and data:
background.add_task(notify_openclaw, session, NotifyContext(event="task.updated", actor_employee_id=actor_employee_id, task=task, changed_fields=data))
background.add_task(
notify_openclaw,
NotifyContext(
event="task.updated",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields=data,
),
)
return Task.model_validate(task)
@router.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
def delete_task(
task_id: int,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
@@ -114,7 +365,13 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp
session.delete(task)
try:
session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task_id, verb="deleted")
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task_id,
verb="deleted",
)
session.commit()
except IntegrityError:
session.rollback()
@@ -125,20 +382,35 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp
@router.get("/task-comments", response_model=list[TaskComment])
def list_task_comments(task_id: int, session: Session = Depends(get_session)):
return session.exec(select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())).all()
return session.exec(
select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())
).all()
@router.post("/task-comments", response_model=TaskComment)
def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
def create_task_comment(
payload: TaskCommentCreate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
if payload.author_employee_id is None:
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id})
payload = TaskCommentCreate(
**{**payload.model_dump(), "author_employee_id": actor_employee_id}
)
c = TaskComment(**payload.model_dump())
session.add(c)
try:
session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=c.task_id, verb="commented")
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=c.task_id,
verb="commented",
)
session.commit()
except IntegrityError:
session.rollback()
@@ -147,5 +419,13 @@ def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks,
session.refresh(c)
task = session.get(Task, c.task_id)
if task is not None:
background.add_task(notify_openclaw, session, NotifyContext(event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c))
background.add_task(
notify_openclaw,
NotifyContext(
event="comment.created",
actor_employee_id=actor_employee_id,
task_id=task.id,
comment_id=c.id,
),
)
return TaskComment.model_validate(c)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import logging
import os
import sys
from typing import Any
def _level() -> str:
return (os.environ.get("LOG_LEVEL") or os.environ.get("UVICORN_LOG_LEVEL") or "INFO").upper()
def configure_logging() -> None:
"""Configure app logging to stream to stdout.
Uvicorn already logs requests, but we want our app/integrations logs to be visible
in the same console stream.
"""
level = getattr(logging, _level(), logging.INFO)
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers (e.g., when autoreload imports twice)
if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
handler.setFormatter(formatter)
root.addHandler(handler)
# Make common noisy loggers respect our level
for name in [
"uvicorn",
"uvicorn.error",
"uvicorn.access",
"sqlalchemy.engine",
"httpx",
"requests",
]:
logging.getLogger(name).setLevel(level)
def log_kv(logger: logging.Logger, msg: str, **kv: Any) -> None:
# Lightweight key-value logging without requiring JSON logging.
if kv:
suffix = " ".join(f"{k}={v!r}" for k, v in kv.items())
logger.info(f"{msg} | {suffix}")
else:
logger.info(msg)

35
backend/app/core/urls.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
def public_api_base_url() -> str:
"""Return a LAN-reachable base URL for the Mission Control API.
Priority:
1) MISSION_CONTROL_BASE_URL env var (recommended)
2) First non-loopback IPv4 from `hostname -I`
Never returns localhost because agents may run on another machine.
"""
import os
import re
import subprocess
explicit = os.environ.get("MISSION_CONTROL_BASE_URL")
if explicit:
return explicit.rstrip("/")
try:
out = subprocess.check_output(["bash", "-lc", "hostname -I"], text=True).strip()
ips = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", out)
for ip in ips:
if ip.startswith("127."):
continue
if ip.startswith("172.17."):
continue
if ip.startswith(("192.168.", "10.", "172.")):
return f"http://{ip}:8000"
except Exception:
pass
return "http://<dev-machine-ip>:8000"

View File

@@ -1,43 +1,51 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Iterable
from sqlmodel import Session, select
from app.db.session import engine
from app.integrations.openclaw import OpenClawClient
from app.models.org import Employee
from app.models.projects import ProjectMember
from app.models.work import Task, TaskComment
logger = logging.getLogger("app.notify")
@dataclass(frozen=True)
class NotifyContext:
"""Notification context.
IMPORTANT: this is passed into FastAPI BackgroundTasks.
Do not store live SQLAlchemy/SQLModel objects here; only ids/primitive data.
"""
event: str # task.created | task.updated | task.assigned | comment.created | status.changed
actor_employee_id: int
task: Task
comment: TaskComment | None = None
task_id: int
comment_id: int | None = None
changed_fields: dict | None = None
def _employee_session_keys(session: Session, employee_ids: Iterable[int]) -> list[str]:
def _employees_with_session_keys(session: Session, employee_ids: Iterable[int]) -> list[Employee]:
ids = sorted({i for i in employee_ids if i is not None})
if not ids:
return []
emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all()
keys: list[str] = []
out: list[Employee] = []
for e in emps:
if not getattr(e, "notify_enabled", True):
continue
sk = getattr(e, "openclaw_session_key", None)
if sk:
keys.append(sk)
return sorted(set(keys))
if getattr(e, "openclaw_session_key", None):
out.append(e)
return out
def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
# Generic, data-driven: PMs are determined by project_members.role.
pms = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all()
pm_ids: set[int] = set()
for m in pms:
@@ -47,89 +55,221 @@ def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
return pm_ids
def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]:
t = ctx.task
def resolve_recipients(
session: Session, ctx: NotifyContext, task: Task, comment: TaskComment | None
) -> set[int]:
recipients: set[int] = set()
if ctx.event == "task.created":
# notify assignee + PMs
if t.assignee_employee_id:
recipients.add(t.assignee_employee_id)
recipients |= _project_pm_employee_ids(session, t.project_id)
if task.assignee_employee_id:
recipients.add(task.assignee_employee_id)
recipients |= _project_pm_employee_ids(session, task.project_id)
elif ctx.event == "task.assigned":
if t.assignee_employee_id:
recipients.add(t.assignee_employee_id)
recipients |= _project_pm_employee_ids(session, t.project_id)
if task.assignee_employee_id:
recipients.add(task.assignee_employee_id)
recipients |= _project_pm_employee_ids(session, task.project_id)
elif ctx.event == "comment.created":
# notify assignee + reviewer + PMs, excluding author
if t.assignee_employee_id:
recipients.add(t.assignee_employee_id)
if t.reviewer_employee_id:
recipients.add(t.reviewer_employee_id)
recipients |= _project_pm_employee_ids(session, t.project_id)
if ctx.comment and ctx.comment.author_employee_id:
recipients.discard(ctx.comment.author_employee_id)
if task.assignee_employee_id:
recipients.add(task.assignee_employee_id)
if task.reviewer_employee_id:
recipients.add(task.reviewer_employee_id)
recipients |= _project_pm_employee_ids(session, task.project_id)
if comment and comment.author_employee_id:
recipients.discard(comment.author_employee_id)
elif ctx.event == "status.changed":
new_status = (getattr(t, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"} and t.reviewer_employee_id:
recipients.add(t.reviewer_employee_id)
recipients |= _project_pm_employee_ids(session, t.project_id)
new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
recipients.add(task.reviewer_employee_id)
recipients |= _project_pm_employee_ids(session, task.project_id)
elif ctx.event == "task.updated":
# conservative: PMs only
recipients |= _project_pm_employee_ids(session, t.project_id)
recipients |= _project_pm_employee_ids(session, task.project_id)
recipients.discard(ctx.actor_employee_id)
return recipients
def build_message(ctx: NotifyContext) -> str:
t = ctx.task
base = f"Task #{t.id}: {t.title}" if t.id is not None else f"Task: {t.title}"
def ensure_employee_provisioned(session: Session, employee_id: int) -> None:
"""Best-effort provisioning of a reviewer/manager so notifications can be delivered."""
if ctx.event == "task.assigned":
return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress."
emp = session.get(Employee, employee_id)
if emp is None:
return
if not getattr(emp, "notify_enabled", True):
return
if getattr(emp, "openclaw_session_key", None):
return
client = OpenClawClient.from_env()
if client is None:
logger.warning(
"ensure_employee_provisioned: missing OpenClaw env", extra={"employee_id": employee_id}
)
return
prompt = (
f"You are {emp.name} (employee_id={emp.id}).\n"
"You are a reviewer/manager in Mission Control.\n"
"When you get a review request, open Mission Control and approve or request changes.\n"
)
try:
res = client.tools_invoke(
"sessions_spawn",
{"task": prompt, "label": f"employee:{emp.id}:{emp.name}"},
timeout_s=20.0,
)
details = (res.get("result") or {}).get("details") or {}
sk = details.get("childSessionKey") or details.get("sessionKey")
if sk:
emp.openclaw_session_key = sk
session.add(emp)
session.commit()
logger.info(
"ensure_employee_provisioned: provisioned",
extra={"employee_id": emp.id, "session_key": sk},
)
except Exception:
session.rollback()
logger.exception("ensure_employee_provisioned: failed", extra={"employee_id": employee_id})
def build_message(
*,
ctx: NotifyContext,
task: Task,
comment: TaskComment | None,
recipient: Employee,
base_url: str,
) -> str:
base = f"Task #{task.id}: {task.title}" if task.id is not None else f"Task: {task.title}"
if ctx.event in {"task.created", "task.assigned"} and recipient.employee_type == "agent":
desc = (task.description or "").strip()
if len(desc) > 500:
desc = desc[:497] + "..."
desc_block = f"\n\nDescription:\n{desc}" if desc else ""
return (
f"{base}\n\n"
f"Set BASE={base_url}\n\n"
"You are the assignee. Start NOW (use the exec tool to run these curl commands):\n"
f"1) curl -sS -X PATCH $BASE/tasks/{task.id} -H 'X-Actor-Employee-Id: {recipient.id}' -H 'Content-Type: application/json' -d '{{\"status\":\"in_progress\"}}'\n"
f"2) curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: {recipient.id}' -H 'Content-Type: application/json' -d '{{\"task_id\":{task.id},\"body\":\"Plan: ... Next: ...\"}}'\n"
"3) Do the work\n"
"4) Post progress updates via POST $BASE/task-comments (same headers)\n"
f"5) When complete: set status=review (assignee cannot set done) and wait for manager approval\n"
f"{desc_block}"
)
if ctx.event == "comment.created":
snippet = ""
if ctx.comment and ctx.comment.body:
snippet = ctx.comment.body.strip().replace("\n", " ")
if comment and comment.body:
snippet = comment.body.strip().replace("\n", " ")
if len(snippet) > 180:
snippet = snippet[:177] + "..."
snippet = f"\nComment: {snippet}"
return f"New comment on {base}.{snippet}\nWork ONE task only; reply/update in Mission Control."
return f"New comment on {base}.{snippet}\nPlease review and respond in Mission Control."
if ctx.event == "status.changed":
return f"Status changed on {base}{t.status}.\nWork ONE task only; update Mission Control with next step."
new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"}:
return (
f"Review requested for {base}.\n"
"As the reviewer/manager, you must:\n"
"1) Read the task + latest assignee comments\n"
"2) Decide: approve or request changes\n"
"3) Leave an audit comment explaining your decision (required)\n"
f"4) Submit decision via POST /tasks/{task.id}/review (decision=approve|changes)\n"
"Approve → task becomes done. Changes → task returns to in_progress and assignee is notified."
)
return (
f"Status changed on {base}{task.status}.\n"
"Please review and respond in Mission Control."
)
if ctx.event == "task.created":
return f"New task created: {base}.\nWork ONE task only; add acceptance criteria / next step in Mission Control."
return f"New task created: {base}.\nPlease review and respond in Mission Control."
return f"Update on {base}.\nWork ONE task only; update Mission Control."
if ctx.event == "task.assigned":
return f"Assigned: {base}.\nPlease review and respond in Mission Control."
return f"Update on {base}.\nPlease review and respond in Mission Control."
def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
def notify_openclaw(ctx: NotifyContext) -> None:
"""Send OpenClaw notifications.
Runs in BackgroundTasks; opens its own DB session for safety.
"""
client = OpenClawClient.from_env()
logger.info(
"notify_openclaw: start",
extra={"event": ctx.event, "task_id": ctx.task_id, "actor": ctx.actor_employee_id},
)
if client is None:
logger.warning("notify_openclaw: skipped (missing OpenClaw env)")
return
recipient_ids = resolve_recipients(session, ctx)
session_keys = _employee_session_keys(session, recipient_ids)
if not session_keys:
return
with Session(engine) as session:
task = session.get(Task, ctx.task_id)
if task is None:
logger.warning("notify_openclaw: task not found", extra={"task_id": ctx.task_id})
return
message = build_message(ctx)
comment = session.get(TaskComment, ctx.comment_id) if ctx.comment_id else None
for sk in session_keys:
try:
client.tools_invoke(
"sessions_send",
{"sessionKey": sk, "message": message},
timeout_s=3.0,
if ctx.event == "status.changed":
new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
ensure_employee_provisioned(session, int(task.reviewer_employee_id))
recipient_ids = resolve_recipients(session, ctx, task, comment)
logger.info(
"notify_openclaw: recipients resolved", extra={"recipient_ids": sorted(recipient_ids)}
)
recipients = _employees_with_session_keys(session, recipient_ids)
if not recipients:
logger.info("notify_openclaw: no recipients with session keys")
return
# base URL used in agent messages
base_url = __import__(
"app.core.urls", fromlist=["public_api_base_url"]
).public_api_base_url()
for e in recipients:
sk = getattr(e, "openclaw_session_key", None)
if not sk:
continue
message = build_message(
ctx=ctx,
task=task,
comment=comment,
recipient=e,
base_url=base_url,
)
except Exception:
# best-effort; never break Mission Control writes
continue
try:
client.tools_invoke(
"sessions_send",
{"sessionKey": sk, "message": message},
timeout_s=30.0,
)
except Exception:
# keep the log, but avoid giant stack spam unless debugging
logger.warning(
"notify_openclaw: sessions_send failed",
extra={
"event": ctx.event,
"task_id": ctx.task_id,
"to_employee_id": getattr(e, "id", None),
"session_key": sk,
},
)
continue

View File

@@ -1,9 +1,14 @@
from __future__ import annotations
import logging
import os
import time
from typing import Any
import requests
from requests.exceptions import ReadTimeout, RequestException
logger = logging.getLogger("app.openclaw")
class OpenClawClient:
@@ -13,22 +18,71 @@ class OpenClawClient:
@classmethod
def from_env(cls) -> "OpenClawClient | None":
# Ensure .env is loaded into os.environ (pydantic Settings reads env_file but
# does not automatically populate os.environ).
try:
from dotenv import load_dotenv
load_dotenv(override=False)
except Exception:
pass
url = os.environ.get("OPENCLAW_GATEWAY_URL")
token = os.environ.get("OPENCLAW_GATEWAY_TOKEN")
if not url or not token:
return None
return cls(url, token)
def tools_invoke(self, tool: str, args: dict[str, Any], *, session_key: str | None = None, timeout_s: float = 5.0) -> dict[str, Any]:
def tools_invoke(
self,
tool: str,
args: dict[str, Any],
*,
session_key: str | None = None,
timeout_s: float = 10.0,
) -> dict[str, Any]:
payload: dict[str, Any] = {"tool": tool, "args": args}
logger.info(
"openclaw.tools_invoke",
extra={"tool": tool, "has_session_key": bool(session_key), "timeout_s": timeout_s},
)
if session_key is not None:
payload["sessionKey"] = session_key
r = requests.post(
f"{self.base_url}/tools/invoke",
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
json=payload,
timeout=timeout_s,
)
r.raise_for_status()
return r.json()
last_err: Exception | None = None
# Retry a few times; the gateway can be busy and respond slowly.
for attempt in range(4):
try:
r = requests.post(
f"{self.base_url}/tools/invoke",
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
json=payload,
# connect timeout, read timeout
timeout=(2.0, timeout_s),
)
r.raise_for_status()
logger.info(
"openclaw.tools_invoke: ok",
extra={"tool": tool, "status": r.status_code, "attempt": attempt + 1},
)
return r.json()
except ReadTimeout as e:
last_err = e
logger.warning(
"openclaw.tools_invoke: timeout",
extra={"tool": tool, "attempt": attempt + 1, "timeout_s": timeout_s},
)
time.sleep(0.5 * (2**attempt))
except RequestException as e:
last_err = e
logger.warning(
"openclaw.tools_invoke: request error",
extra={"tool": tool, "attempt": attempt + 1, "error": str(e)},
)
time.sleep(0.5 * (2**attempt))
assert last_err is not None
raise last_err

View File

@@ -8,8 +8,11 @@ from app.api.org import router as org_router
from app.api.projects import router as projects_router
from app.api.work import router as work_router
from app.core.config import settings
from app.core.logging import configure_logging
from app.db.session import init_db
configure_logging()
app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]

View File

@@ -1,5 +1,5 @@
from app.models.activity import Activity
from app.models.org import Department, Team, Employee
from app.models.org import Department, Employee, Team
from app.models.projects import Project, ProjectMember
from app.models.work import Task, TaskComment

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import Optional
from sqlmodel import Field, SQLModel

View File

@@ -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

9
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[tool.black]
line-length = 100
target-version = ["py312"]
extend-exclude = '(\.venv|alembic/versions)'
[tool.isort]
profile = "black"
line_length = 100
skip = [".venv", "alembic/versions"]

View File

@@ -0,0 +1,4 @@
black==24.10.0
isort==5.13.2
flake8==7.1.1
pre-commit==4.1.0

View File

@@ -2,7 +2,7 @@ fastapi
uvicorn[standard]
sqlmodel
alembic
psycopg2-binary
psycopg[binary]
python-dotenv
pydantic-settings
requests

View File

@@ -0,0 +1,37 @@
# DB reset + seed (dev-machine)
This repo uses Alembic migrations as schema source-of-truth.
## Reset to the current seed
```bash
cd backend
./scripts/reset_db.sh
```
Environment variables (optional):
- `DB_NAME` (default `openclaw_agency`)
- `DB_USER` (default `postgres`)
- `DB_HOST` (default `127.0.0.1`)
- `DB_PORT` (default `5432`)
- `DB_PASSWORD` (default `postgres`)
## Updating the seed
The seed is a **data-only** dump (not schema). Regenerate it from the current DB state:
```bash
cd backend
PGPASSWORD=postgres pg_dump \
--data-only \
--column-inserts \
--disable-triggers \
--no-owner \
--no-privileges \
-U postgres -h 127.0.0.1 -d openclaw_agency \
> scripts/seed_data.sql
# IMPORTANT: do not include alembic_version in the seed (migrations already set it)
# (our committed seed already has this removed)
```

9
backend/scripts/lint.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
. .venv/bin/activate
python -m black .
python -m isort .
python -m flake8 .

55
backend/scripts/reset_db.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
DB_NAME=${DB_NAME:-openclaw_agency}
DB_USER=${DB_USER:-postgres}
DB_HOST=${DB_HOST:-127.0.0.1}
DB_PORT=${DB_PORT:-5432}
# Never hardcode passwords in git. Prefer:
# - DB_PASSWORD env var, or
# - infer from backend/.env DATABASE_URL
DB_PASSWORD=${DB_PASSWORD:-}
cd "$(dirname "$0")/.."
if [[ -z "${DB_PASSWORD}" ]] && [[ -f .env ]]; then
DB_PASSWORD=$(python3 - <<'PY'
import os
from pathlib import Path
from urllib.parse import urlparse
def parse_database_url(url: str) -> str:
# supports postgresql+psycopg://user:pass@host:port/db
u = urlparse(url)
return u.password or ""
for line in Path('.env').read_text().splitlines():
if line.startswith('DATABASE_URL='):
print(parse_database_url(line.split('=',1)[1].strip()))
break
PY
)
fi
if [[ -z "${DB_PASSWORD}" ]]; then
echo "ERROR: DB_PASSWORD not set and could not infer it from backend/.env DATABASE_URL" >&2
echo "Set DB_PASSWORD=... or create backend/.env with DATABASE_URL" >&2
exit 2
fi
export PGPASSWORD="$DB_PASSWORD"
# 1) wipe schema
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
# 2) migrate
. .venv/bin/activate
alembic upgrade head
# 3) seed
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-f scripts/seed_data.sql
echo "Reset complete: $DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"

View File

@@ -0,0 +1,50 @@
-- Mission Control seed data (minimal)
-- Keep this data-only seed small and deterministic.
-- NOTE: Do NOT include alembic_version here; migrations manage it.
SET client_min_messages = warning;
SET row_security = off;
-- Disable triggers to avoid FK ordering issues during seed.
ALTER TABLE public.employees DISABLE TRIGGER ALL;
ALTER TABLE public.departments DISABLE TRIGGER ALL;
ALTER TABLE public.teams DISABLE TRIGGER ALL;
ALTER TABLE public.projects DISABLE TRIGGER ALL;
ALTER TABLE public.tasks DISABLE TRIGGER ALL;
ALTER TABLE public.task_comments DISABLE TRIGGER ALL;
ALTER TABLE public.project_members DISABLE TRIGGER ALL;
ALTER TABLE public.activities DISABLE TRIGGER ALL;
-- Employees (keep only Abhimanyu)
INSERT INTO public.employees (id, name, employee_type, department_id, manager_id, title, status, openclaw_session_key, notify_enabled, team_id)
VALUES
(2, 'Abhimanyu', 'human', NULL, NULL, 'CEO', 'active', NULL, false, NULL)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
employee_type = EXCLUDED.employee_type,
department_id = EXCLUDED.department_id,
manager_id = EXCLUDED.manager_id,
title = EXCLUDED.title,
status = EXCLUDED.status,
openclaw_session_key = EXCLUDED.openclaw_session_key,
notify_enabled = EXCLUDED.notify_enabled,
team_id = EXCLUDED.team_id;
-- Fix sequences (avoid PK reuse after explicit ids)
SELECT setval('employees_id_seq', (SELECT COALESCE(max(id), 1) FROM public.employees));
SELECT setval('departments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.departments));
SELECT setval('teams_id_seq', (SELECT COALESCE(max(id), 1) FROM public.teams));
SELECT setval('projects_id_seq', (SELECT COALESCE(max(id), 1) FROM public.projects));
SELECT setval('tasks_id_seq', (SELECT COALESCE(max(id), 1) FROM public.tasks));
SELECT setval('task_comments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.task_comments));
SELECT setval('project_members_id_seq', (SELECT COALESCE(max(id), 1) FROM public.project_members));
SELECT setval('activities_id_seq', (SELECT COALESCE(max(id), 1) FROM public.activities));
ALTER TABLE public.employees ENABLE TRIGGER ALL;
ALTER TABLE public.departments ENABLE TRIGGER ALL;
ALTER TABLE public.teams ENABLE TRIGGER ALL;
ALTER TABLE public.projects ENABLE TRIGGER ALL;
ALTER TABLE public.tasks ENABLE TRIGGER ALL;
ALTER TABLE public.task_comments ENABLE TRIGGER ALL;
ALTER TABLE public.project_members ENABLE TRIGGER ALL;
ALTER TABLE public.activities ENABLE TRIGGER ALL;

View File

@@ -1 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 129 B

View File

@@ -1 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -10,6 +10,7 @@ export interface Employee {
name: string;
employee_type: string;
department_id?: number | null;
team_id?: number | null;
manager_id?: number | null;
title?: string | null;
status?: string;

View File

@@ -9,6 +9,7 @@ export interface EmployeeCreate {
name: string;
employee_type: string;
department_id?: number | null;
team_id?: number | null;
manager_id?: number | null;
title?: string | null;
status?: string;

View File

@@ -9,6 +9,7 @@ export interface EmployeeUpdate {
name?: string | null;
employee_type?: string | null;
department_id?: number | null;
team_id?: number | null;
manager_id?: number | null;
title?: string | null;
status?: string | null;

View File

@@ -23,6 +23,7 @@ export * from "./hTTPValidationError";
export * from "./listActivitiesActivitiesGetParams";
export * from "./listTaskCommentsTaskCommentsGetParams";
export * from "./listTasksTasksGetParams";
export * from "./listTeamsTeamsGetParams";
export * from "./project";
export * from "./projectCreate";
export * from "./projectMember";
@@ -31,5 +32,9 @@ export * from "./task";
export * from "./taskComment";
export * from "./taskCommentCreate";
export * from "./taskCreate";
export * from "./taskReviewDecision";
export * from "./taskUpdate";
export * from "./team";
export * from "./teamCreate";
export * from "./teamUpdate";
export * from "./validationError";

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export type ListTeamsTeamsGetParams = {
department_id?: number | null;
};

View File

@@ -9,4 +9,5 @@ export interface Project {
id?: number | null;
name: string;
status?: string;
team_id?: number | null;
}

View File

@@ -8,4 +8,5 @@
export interface ProjectCreate {
name: string;
status?: string;
team_id?: number | null;
}

View File

@@ -8,4 +8,5 @@
export interface ProjectUpdate {
name?: string | null;
status?: string | null;
team_id?: number | null;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Team {
id?: number | null;
name: string;
department_id: number;
lead_employee_id?: number | null;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TeamCreate {
name: string;
department_id: number;
lead_employee_id?: number | null;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TeamUpdate {
name?: string | null;
department_id?: number | null;
lead_employee_id?: number | null;
}

View File

@@ -28,6 +28,10 @@ import type {
EmployeeCreate,
EmployeeUpdate,
HTTPValidationError,
ListTeamsTeamsGetParams,
Team,
TeamCreate,
TeamUpdate,
} from ".././model";
import { customFetch } from "../../mutator";
@@ -327,6 +331,440 @@ export const useCreateDepartmentDepartmentsPost = <
queryClient,
);
};
/**
* @summary List Teams
*/
export type listTeamsTeamsGetResponse200 = {
data: Team[];
status: 200;
};
export type listTeamsTeamsGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type listTeamsTeamsGetResponseSuccess = listTeamsTeamsGetResponse200 & {
headers: Headers;
};
export type listTeamsTeamsGetResponseError = listTeamsTeamsGetResponse422 & {
headers: Headers;
};
export type listTeamsTeamsGetResponse =
| listTeamsTeamsGetResponseSuccess
| listTeamsTeamsGetResponseError;
export const getListTeamsTeamsGetUrl = (params?: ListTeamsTeamsGetParams) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/teams?${stringifiedParams}`
: `/teams`;
};
export const listTeamsTeamsGet = async (
params?: ListTeamsTeamsGetParams,
options?: RequestInit,
): Promise<listTeamsTeamsGetResponse> => {
return customFetch<listTeamsTeamsGetResponse>(
getListTeamsTeamsGetUrl(params),
{
...options,
method: "GET",
},
);
};
export const getListTeamsTeamsGetQueryKey = (
params?: ListTeamsTeamsGetParams,
) => {
return [`/teams`, ...(params ? [params] : [])] as const;
};
export const getListTeamsTeamsGetQueryOptions = <
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListTeamsTeamsGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listTeamsTeamsGet>>
> = ({ signal }) => listTeamsTeamsGet(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListTeamsTeamsGetQueryResult = NonNullable<
Awaited<ReturnType<typeof listTeamsTeamsGet>>
>;
export type ListTeamsTeamsGetQueryError = HTTPValidationError;
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params: undefined | ListTeamsTeamsGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
Awaited<ReturnType<typeof listTeamsTeamsGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
Awaited<ReturnType<typeof listTeamsTeamsGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List Teams
*/
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getListTeamsTeamsGetQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Create Team
*/
export type createTeamTeamsPostResponse200 = {
data: Team;
status: 200;
};
export type createTeamTeamsPostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type createTeamTeamsPostResponseSuccess =
createTeamTeamsPostResponse200 & {
headers: Headers;
};
export type createTeamTeamsPostResponseError =
createTeamTeamsPostResponse422 & {
headers: Headers;
};
export type createTeamTeamsPostResponse =
| createTeamTeamsPostResponseSuccess
| createTeamTeamsPostResponseError;
export const getCreateTeamTeamsPostUrl = () => {
return `/teams`;
};
export const createTeamTeamsPost = async (
teamCreate: TeamCreate,
options?: RequestInit,
): Promise<createTeamTeamsPostResponse> => {
return customFetch<createTeamTeamsPostResponse>(getCreateTeamTeamsPostUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(teamCreate),
});
};
export const getCreateTeamTeamsPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
> => {
const mutationKey = ["createTeamTeamsPost"];
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<ReturnType<typeof createTeamTeamsPost>>,
{ data: TeamCreate }
> = (props) => {
const { data } = props ?? {};
return createTeamTeamsPost(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateTeamTeamsPostMutationResult = NonNullable<
Awaited<ReturnType<typeof createTeamTeamsPost>>
>;
export type CreateTeamTeamsPostMutationBody = TeamCreate;
export type CreateTeamTeamsPostMutationError = HTTPValidationError;
/**
* @summary Create Team
*/
export const useCreateTeamTeamsPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
> => {
return useMutation(
getCreateTeamTeamsPostMutationOptions(options),
queryClient,
);
};
/**
* @summary Update Team
*/
export type updateTeamTeamsTeamIdPatchResponse200 = {
data: Team;
status: 200;
};
export type updateTeamTeamsTeamIdPatchResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type updateTeamTeamsTeamIdPatchResponseSuccess =
updateTeamTeamsTeamIdPatchResponse200 & {
headers: Headers;
};
export type updateTeamTeamsTeamIdPatchResponseError =
updateTeamTeamsTeamIdPatchResponse422 & {
headers: Headers;
};
export type updateTeamTeamsTeamIdPatchResponse =
| updateTeamTeamsTeamIdPatchResponseSuccess
| updateTeamTeamsTeamIdPatchResponseError;
export const getUpdateTeamTeamsTeamIdPatchUrl = (teamId: number) => {
return `/teams/${teamId}`;
};
export const updateTeamTeamsTeamIdPatch = async (
teamId: number,
teamUpdate: TeamUpdate,
options?: RequestInit,
): Promise<updateTeamTeamsTeamIdPatchResponse> => {
return customFetch<updateTeamTeamsTeamIdPatchResponse>(
getUpdateTeamTeamsTeamIdPatchUrl(teamId),
{
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(teamUpdate),
},
);
};
export const getUpdateTeamTeamsTeamIdPatchMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
> => {
const mutationKey = ["updateTeamTeamsTeamIdPatch"];
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<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
{ teamId: number; data: TeamUpdate }
> = (props) => {
const { teamId, data } = props ?? {};
return updateTeamTeamsTeamIdPatch(teamId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateTeamTeamsTeamIdPatchMutationResult = NonNullable<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>
>;
export type UpdateTeamTeamsTeamIdPatchMutationBody = TeamUpdate;
export type UpdateTeamTeamsTeamIdPatchMutationError = HTTPValidationError;
/**
* @summary Update Team
*/
export const useUpdateTeamTeamsTeamIdPatch = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
> => {
return useMutation(
getUpdateTeamTeamsTeamIdPatchMutationOptions(options),
queryClient,
);
};
/**
* @summary Update Department
*/

View File

@@ -28,6 +28,7 @@ import type {
TaskComment,
TaskCommentCreate,
TaskCreate,
TaskReviewDecision,
TaskUpdate,
} from ".././model";
@@ -351,6 +352,246 @@ export const useCreateTaskTasksPost = <
queryClient,
);
};
/**
* @summary Dispatch Task
*/
export type dispatchTaskTasksTaskIdDispatchPostResponse200 = {
data: unknown;
status: 200;
};
export type dispatchTaskTasksTaskIdDispatchPostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type dispatchTaskTasksTaskIdDispatchPostResponseSuccess =
dispatchTaskTasksTaskIdDispatchPostResponse200 & {
headers: Headers;
};
export type dispatchTaskTasksTaskIdDispatchPostResponseError =
dispatchTaskTasksTaskIdDispatchPostResponse422 & {
headers: Headers;
};
export type dispatchTaskTasksTaskIdDispatchPostResponse =
| dispatchTaskTasksTaskIdDispatchPostResponseSuccess
| dispatchTaskTasksTaskIdDispatchPostResponseError;
export const getDispatchTaskTasksTaskIdDispatchPostUrl = (taskId: number) => {
return `/tasks/${taskId}/dispatch`;
};
export const dispatchTaskTasksTaskIdDispatchPost = async (
taskId: number,
options?: RequestInit,
): Promise<dispatchTaskTasksTaskIdDispatchPostResponse> => {
return customFetch<dispatchTaskTasksTaskIdDispatchPostResponse>(
getDispatchTaskTasksTaskIdDispatchPostUrl(taskId),
{
...options,
method: "POST",
},
);
};
export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
> => {
const mutationKey = ["dispatchTaskTasksTaskIdDispatchPost"];
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<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
{ taskId: number }
> = (props) => {
const { taskId } = props ?? {};
return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>
>;
export type DispatchTaskTasksTaskIdDispatchPostMutationError =
HTTPValidationError;
/**
* @summary Dispatch Task
*/
export const useDispatchTaskTasksTaskIdDispatchPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
> => {
return useMutation(
getDispatchTaskTasksTaskIdDispatchPostMutationOptions(options),
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<reviewTaskTasksTaskIdReviewPostResponse> => {
return customFetch<reviewTaskTasksTaskIdReviewPostResponse>(
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<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
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<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
{ taskId: number; data: TaskReviewDecision }
> = (props) => {
const { taskId, data } = props ?? {};
return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>
>;
export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision;
export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError;
/**
* @summary Review Task
*/
export const useReviewTaskTasksTaskIdReviewPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
> => {
return useMutation(
getReviewTaskTasksTaskIdReviewPostMutationOptions(options),
queryClient,
);
};
/**
* @summary Update Task
*/

View File

@@ -10,6 +10,7 @@ const NAV = [
{ href: "/projects", label: "Projects" },
{ href: "/kanban", label: "Kanban" },
{ href: "/departments", label: "Departments" },
{ href: "/teams", label: "Teams" },
{ href: "/people", label: "People" },
];

View File

@@ -13,28 +13,58 @@ import {
useCreateEmployeeEmployeesPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost,
useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost,
} from "@/api/generated/org/org";
export default function PeoplePage() {
const [actorId] = useState(() => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem("actor_employee_id") ?? "";
} catch {
return "";
}
});
const [name, setName] = useState("");
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
const [title, setTitle] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [teamId, setTeamId] = useState<string>("");
const [managerId, setManagerId] = useState<string>("");
const employees = useListEmployeesEmployeesGet();
const departments = useListDepartmentsDepartmentsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]);
const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost();
const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost();
const createEmployee = useCreateEmployeeEmployeesPost({
mutation: {
onSuccess: () => {
onSuccess: async (res) => {
setName("");
setTitle("");
setDepartmentId("");
setTeamId("");
setManagerId("");
// If an agent was created but not yet provisioned, provision immediately so it can receive tasks.
try {
const e = (res as any)?.data?.data ?? (res as any)?.data ?? null;
if (e?.employee_type === "agent" && !e.openclaw_session_key) {
await provisionEmployee.mutateAsync({ employeeId: e.id! });
}
} catch {
// ignore; UI will show unprovisioned state
}
employees.refetch();
teams.refetch();
},
},
});
@@ -47,6 +77,14 @@ export default function PeoplePage() {
return m;
}, [departmentList]);
const teamNameById = useMemo(() => {
const m = new Map<number, string>();
for (const t of teamList) {
if (t.id != null) m.set(t.id, t.name);
}
return m;
}, [teamList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
@@ -88,6 +126,14 @@ export default function PeoplePage() {
</option>
))}
</Select>
<Select value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">(no team)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ""}>
{t.name}
</option>
))}
</Select>
<Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
<option value="">(no manager)</option>
{employeeList.map((e) => (
@@ -104,6 +150,7 @@ export default function PeoplePage() {
employee_type: employeeType,
title: title.trim() ? title : null,
department_id: departmentId ? Number(departmentId) : null,
team_id: teamId ? Number(teamId) : null,
manager_id: managerId ? Number(managerId) : null,
status: "active",
},
@@ -142,6 +189,7 @@ export default function PeoplePage() {
<div className="mt-2 text-sm text-muted-foreground">
{e.title ? <span>{e.title} · </span> : null}
{e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </span> : null}
{e.team_id ? <span>Team: {teamNameById.get(e.team_id) ?? `Team#${e.team_id}`} · </span> : null}
{e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
</div>
</li>

View File

@@ -4,7 +4,13 @@ import { useState } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
@@ -14,15 +20,16 @@ import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import {
useCreateTaskTasksPost,
useDeleteTaskTasksTaskIdDelete,
useDispatchTaskTasksTaskIdDispatchPost,
useListTaskCommentsTaskCommentsGet,
useListTasksTasksGet,
useUpdateTaskTasksTaskIdPatch,
useDeleteTaskTasksTaskIdDelete,
useCreateTaskCommentTaskCommentsPost,
useListTaskCommentsTaskCommentsGet,
} from "@/api/generated/work/work";
import {
useListProjectMembersProjectsProjectIdMembersGet,
useAddProjectMemberProjectsProjectIdMembersPost,
useListProjectMembersProjectsProjectIdMembersGet,
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
} from "@/api/generated/projects/projects";
@@ -52,6 +59,10 @@ export default function ProjectDetailPage() {
const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const eligibleAssignees = employeeList.filter(
(e) => e.employee_type !== "agent" || !!e.openclaw_session_key,
);
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
const memberList = members.data?.status === 200 ? members.data.data : [];
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
@@ -75,6 +86,11 @@ export default function ProjectDetailPage() {
const deleteTask = useDeleteTaskTasksTaskIdDelete({
mutation: { onSuccess: () => tasks.refetch() },
});
const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({
mutation: {
onSuccess: () => tasks.refetch(),
},
});
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
@@ -109,6 +125,11 @@ export default function ProjectDetailPage() {
return map;
})();
const employeeById = new Map<number, (typeof employeeList)[number]>();
for (const e of employeeList) {
if (e.id != null) employeeById.set(Number(e.id), e);
}
const employeeName = (id: number | null | undefined) =>
employeeList.find((e) => e.id === id)?.name ?? "—";
@@ -127,16 +148,40 @@ export default function ProjectDetailPage() {
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
<div className="mb-4 text-sm text-muted-foreground">Loading</div>
) : null}
{projects.error ? <div className="mb-4 text-sm text-destructive">{(projects.error as Error).message}</div> : null}
{employees.error ? <div className="mb-4 text-sm text-destructive">{(employees.error as Error).message}</div> : null}
{members.error ? <div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div> : null}
{tasks.error ? <div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div> : null}
{projects.error ? (
<div className="mb-4 text-sm text-destructive">
{(projects.error as Error).message}
</div>
) : null}
{employees.error ? (
<div className="mb-4 text-sm text-destructive">
{(employees.error as Error).message}
</div>
) : null}
{members.error ? (
<div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div>
) : null}
{tasks.error ? (
<div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
) : null}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{project?.name ?? `Project #${projectId}`}</h1>
<p className="mt-1 text-sm text-muted-foreground">Project detail: staffing + tasks.</p>
<h1 className="text-2xl font-semibold tracking-tight">
{project?.name ?? `Project #${projectId}`}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Project detail: staffing + tasks.
</p>
</div>
<Button variant="outline" onClick={() => { tasks.refetch(); members.refetch(); }} disabled={tasks.isFetching || members.isFetching}>
<Button
variant="outline"
onClick={() => {
tasks.refetch();
members.refetch();
}}
disabled={tasks.isFetching || members.isFetching}
>
Refresh
</Button>
</div>
@@ -148,14 +193,31 @@ export default function ProjectDetailPage() {
<CardDescription>Project-scoped tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{createTask.error ? <div className="text-sm text-destructive">{(createTask.error as Error).message}</div> : null}
<Input placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
<Textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} />
{createTask.error ? (
<div className="text-sm text-destructive">
{(createTask.error as Error).message}
</div>
) : null}
<Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Textarea
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-1 gap-2">
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
<Select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
>
<option value="">Assignee</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
{eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
</div>
@@ -184,28 +246,43 @@ export default function ProjectDetailPage() {
<CardDescription>Project members</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Select onChange={(e) => {
const empId = e.target.value;
if (!empId) return;
addMember.mutate({ projectId, data: { project_id: projectId, employee_id: Number(empId), role: "member" } });
e.currentTarget.value = "";
}}>
<Select
onChange={(e) => {
const empId = e.target.value;
if (!empId) return;
addMember.mutate({
projectId,
data: { project_id: projectId, employee_id: Number(empId), role: "member" },
});
e.currentTarget.value = "";
}}
>
<option value="">Add member</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
{eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
{addMember.error ? (
<div className="text-xs text-destructive">{(addMember.error as Error).message}</div>
<div className="text-xs text-destructive">
{(addMember.error as Error).message}
</div>
) : null}
<ul className="space-y-2">
{projectMembers.map((m) => (
<li key={m.id ?? `${m.project_id}-${m.employee_id}`} className="rounded-md border p-2 text-sm">
<li
key={m.id ?? `${m.project_id}-${m.employee_id}`}
className="rounded-md border p-2 text-sm"
>
<div className="flex items-center justify-between gap-2">
<div>{employeeName(m.employee_id)}</div>
<Button
variant="outline"
onClick={() => { if (m.id == null) return; removeMember.mutate({ projectId, memberId: Number(m.id) }); }}
onClick={() => {
if (m.id == null) return;
removeMember.mutate({ projectId, memberId: Number(m.id) });
}}
>
Remove
</Button>
@@ -215,17 +292,25 @@ export default function ProjectDetailPage() {
placeholder="Role (e.g., PM, QA, Dev)"
defaultValue={m.role ?? ""}
onBlur={(e) =>
m.id == null ? undefined : updateMember.mutate({
projectId,
memberId: Number(m.id),
data: { project_id: projectId, employee_id: m.employee_id, role: e.currentTarget.value || null },
})
m.id == null
? undefined
: updateMember.mutate({
projectId,
memberId: Number(m.id),
data: {
project_id: projectId,
employee_id: m.employee_id,
role: e.currentTarget.value || null,
},
})
}
/>
</div>
</li>
))}
{projectMembers.length === 0 ? <li className="text-sm text-muted-foreground">No members yet.</li> : null}
{projectMembers.length === 0 ? (
<li className="text-sm text-muted-foreground">No members yet.</li>
) : null}
</ul>
</CardContent>
</Card>
@@ -236,36 +321,125 @@ export default function ProjectDetailPage() {
{STATUSES.map((s) => (
<Card key={s}>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">{s.replace("_", " ")}</CardTitle>
<CardTitle className="text-sm uppercase tracking-wide">
{s.replace("_", " ")}
</CardTitle>
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(tasksByStatus.get(s) ?? []).map((t) => (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div>
<div className="text-xs text-muted-foreground">Assignee: {employeeName(t.assignee_employee_id)}</div>
<div className="mt-2 flex flex-wrap gap-1">
{STATUSES.filter((x) => x !== s).map((x) => (
{(tasksByStatus.get(s) ?? []).map((t) => {
const assignee =
t.assignee_employee_id != null
? employeeById.get(Number(t.assignee_employee_id))
: undefined;
const canTrigger = Boolean(
t.id != null &&
assignee &&
assignee.employee_type === "agent" &&
assignee.openclaw_session_key,
);
const actorId = getActorEmployeeId();
const isReviewer = Boolean(actorId && t.reviewer_employee_id && Number(t.reviewer_employee_id) === actorId);
const canReviewActions = Boolean(t.id != null && isReviewer && (t.status ?? "") === "review");
return (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div>
<div className="text-xs text-muted-foreground">
Assignee: {employeeName(t.assignee_employee_id)}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{STATUSES.filter((x) => x !== s).map((x) => (
<Button
key={x}
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: x },
})
}
>
{x}
</Button>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Button
key={x}
variant="outline"
size="sm"
onClick={() => updateTask.mutate({ taskId: Number(t.id), data: { status: x } })}
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
>
{x}
Comments
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => dispatchTask.mutate({ taskId: Number(t.id) })}
disabled={!canTrigger || dispatchTask.isPending}
title={
canTrigger
? "Send a dispatch message to the assigned agent"
: "Only available when the assignee is a provisioned agent"
}
>
Trigger
</Button>
{canReviewActions ? (
<>
<Button
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: "done" },
})
}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
title="Leave a comment asking for changes, then move status back to in_progress"
>
Request changes
</Button>
</>
) : null}
<Button
variant="destructive"
size="sm"
onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}
>
Delete
</Button>
</div>
{dispatchTask.error ? (
<div className="mt-2 text-xs text-destructive">
{(dispatchTask.error as Error).message}
</div>
) : null}
</div>
<div className="mt-2 flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setCommentTaskId(Number(t.id)); setReplyToCommentId(null); }}>
Comments
</Button>
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
Delete
</Button>
</div>
</div>
))}
);
})}
{(tasksByStatus.get(s) ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground">No tasks</div>
) : null}
@@ -282,12 +456,20 @@ export default function ProjectDetailPage() {
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{addComment.error ? <div className="text-sm text-destructive">{(addComment.error as Error).message}</div> : null}
{addComment.error ? (
<div className="text-sm text-destructive">{(addComment.error as Error).message}</div>
) : null}
{replyToCommentId ? (
<div className="rounded-md border bg-muted/40 p-2 text-sm">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">Replying to comment #{replyToCommentId}</div>
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(null)}>
<div className="text-xs text-muted-foreground">
Replying to comment #{replyToCommentId}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(null)}
>
Cancel reply
</Button>
</div>
@@ -323,17 +505,25 @@ export default function ProjectDetailPage() {
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
<div className="text-xs text-muted-foreground">{(c.created_at ? new Date(c.created_at).toLocaleString() : "—")}</div>
<div className="text-xs text-muted-foreground">
{c.created_at ? new Date(c.created_at).toLocaleString() : "—"}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(Number(c.id))}>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(Number(c.id))}
>
Reply
</Button>
</div>
{(c.reply_to_comment_id ? (
{c.reply_to_comment_id ? (
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
<div className="text-muted-foreground">Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}</div>
<div className="text-muted-foreground">
Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}
</div>
</div>
) : null)}
) : null}
<div className="mt-2">{c.body}</div>
</li>
))}

View File

@@ -13,15 +13,21 @@ import {
useListProjectsProjectsGet,
} from "@/api/generated/projects/projects";
import { useListTeamsTeamsGet } from "@/api/generated/org/org";
export default function ProjectsPage() {
const [name, setName] = useState("");
const [teamId, setTeamId] = useState<string>("");
const projects = useListProjectsProjectsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const teamList = teams.data?.status === 200 ? teams.data.data : [];
const createProject = useCreateProjectProjectsPost({
mutation: {
onSuccess: () => {
setName("");
setTeamId("");
projects.refetch();
},
},
@@ -48,8 +54,17 @@ export default function ProjectsPage() {
{projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null}
<div className={styles.list}>
<Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Owning team</span>
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} style={{ flex: 1, padding: '6px 8px', borderRadius: 6, border: '1px solid #333', background: 'transparent' }}>
<option value="">(none)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ''}>{t.name}</option>
))}
</select>
</div>
<Button
onClick={() => createProject.mutate({ data: { name, status: "active" } })}
onClick={() => createProject.mutate({ data: { name, status: "active", team_id: teamId ? Number(teamId) : null } })}
disabled={!name.trim() || createProject.isPending || projects.isFetching}
>
Create

View File

@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateTeamTeamsPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
} from "@/api/generated/org/org";
export default function TeamsPage() {
const [name, setName] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [leadEmployeeId, setLeadEmployeeId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet();
const employees = useListEmployeesEmployeesGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(
() => (departments.data?.status === 200 ? departments.data.data : []),
[departments.data],
);
const employeeList = useMemo(
() => (employees.data?.status === 200 ? employees.data.data : []),
[employees.data],
);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const deptNameById = useMemo(() => {
const m = new Map<number, string>();
for (const d of departmentList) {
if (d.id != null) m.set(d.id, d.name);
}
return m;
}, [departmentList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
const createTeam = useCreateTeamTeamsPost({
mutation: {
onSuccess: () => {
setName("");
setDepartmentId("");
setLeadEmployeeId("");
teams.refetch();
},
},
});
const sorted = teamList
.slice()
.sort((a, b) => `${deptNameById.get(a.department_id) ?? ""}::${a.name}`.localeCompare(`${deptNameById.get(b.department_id) ?? ""}::${b.name}`));
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Teams</h1>
<p className="mt-1 text-sm text-muted-foreground">Teams live under departments. Projects are owned by teams.</p>
</div>
<Button variant="outline" onClick={() => teams.refetch()} disabled={teams.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>Define a team and attach it to a department.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
<option value="">(select department)</option>
{departmentList.map((d) => (
<option key={d.id ?? d.name} value={d.id ?? ""}>
{d.name}
</option>
))}
</Select>
<Select value={leadEmployeeId} onChange={(e) => setLeadEmployeeId(e.target.value)}>
<option value="">(no lead)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<Button
onClick={() =>
createTeam.mutate({
data: {
name: name.trim(),
department_id: Number(departmentId),
lead_employee_id: leadEmployeeId ? Number(leadEmployeeId) : null,
},
})
}
disabled={!name.trim() || !departmentId || createTeam.isPending}
>
Create
</Button>
{createTeam.error ? <div className="text-sm text-destructive">{(createTeam.error as Error).message}</div> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All teams</CardTitle>
<CardDescription>{sorted.length} total</CardDescription>
</CardHeader>
<CardContent>
{teams.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{teams.error ? <div className="text-sm text-destructive">{(teams.error as Error).message}</div> : null}
{!teams.isLoading && !teams.error ? (
<ul className="space-y-2">
{sorted.map((t) => (
<li key={t.id ?? `${t.department_id}:${t.name}`} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{t.name}</div>
<div className="text-sm text-muted-foreground">{deptNameById.get(t.department_id) ?? `Dept#${t.department_id}`}</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{t.lead_employee_id ? <span>Lead: {empNameById.get(t.lead_employee_id) ?? `Emp#${t.lead_employee_id}`}</span> : <span>No lead</span>}
</div>
</li>
))}
{sorted.length === 0 ? <li className="text-sm text-muted-foreground">No teams yet.</li> : null}
</ul>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}