From e283543ef133a239acb7a341ec7e82bd708ecb32 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 2 Feb 2026 12:51:25 +0000 Subject: [PATCH] Add teams + team ownership (schema + API) --- ...2c1b9c8e12_add_teams_and_team_ownership.py | 73 ++++++++++++++++++ backend/app/api/org.py | 75 ++++++++++++++++++- backend/app/models/__init__.py | 3 +- backend/app/models/org.py | 11 +++ backend/app/models/projects.py | 3 + backend/app/schemas/org.py | 14 ++++ backend/app/schemas/projects.py | 2 + 7 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py diff --git a/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py b/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py new file mode 100644 index 00000000..0898fad3 --- /dev/null +++ b/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py @@ -0,0 +1,73 @@ +"""Add teams and team ownership + +Revision ID: 3f2c1b9c8e12 +Revises: bacd5e6a253d +Create Date: 2026-02-02 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3f2c1b9c8e12" +down_revision = "bacd5e6a253d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1) Teams + op.create_table( + "teams", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("department_id", sa.Integer(), nullable=False), + sa.Column("lead_employee_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["department_id"], ["departments.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["lead_employee_id"], ["employees.id"], ondelete="SET NULL"), + sa.UniqueConstraint("department_id", "name", name="uq_teams_department_id_name"), + ) + op.create_index("ix_teams_name", "teams", ["name"], unique=False) + op.create_index("ix_teams_department_id", "teams", ["department_id"], unique=False) + + # 2) Employees belong to one (optional) team + op.add_column("employees", sa.Column("team_id", sa.Integer(), nullable=True)) + op.create_index("ix_employees_team_id", "employees", ["team_id"], unique=False) + op.create_foreign_key( + "fk_employees_team_id_teams", + "employees", + "teams", + ["team_id"], + ["id"], + ondelete="SET NULL", + ) + + # 3) Projects are owned by teams (not departments) + op.add_column("projects", sa.Column("team_id", sa.Integer(), nullable=True)) + op.create_index("ix_projects_team_id", "projects", ["team_id"], unique=False) + op.create_foreign_key( + "fk_projects_team_id_teams", + "projects", + "teams", + ["team_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_projects_team_id_teams", "projects", type_="foreignkey") + op.drop_index("ix_projects_team_id", table_name="projects") + op.drop_column("projects", "team_id") + + op.drop_constraint("fk_employees_team_id_teams", "employees", type_="foreignkey") + op.drop_index("ix_employees_team_id", table_name="employees") + op.drop_column("employees", "team_id") + + op.drop_index("ix_teams_department_id", table_name="teams") + op.drop_index("ix_teams_name", table_name="teams") + op.drop_table("teams") diff --git a/backend/app/api/org.py b/backend/app/api/org.py index 571a4894..a06309cd 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -7,8 +7,15 @@ from sqlmodel import Session, select from app.api.utils import get_actor_employee_id, log_activity from app.db.session import get_session from app.integrations.openclaw import OpenClawClient -from app.models.org import Department, Employee -from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate +from app.models.org import Department, Team, Employee +from app.schemas.org import ( + DepartmentCreate, + DepartmentUpdate, + TeamCreate, + TeamUpdate, + EmployeeCreate, + EmployeeUpdate, +) router = APIRouter(tags=["org"]) @@ -127,6 +134,70 @@ def list_departments(session: Session = Depends(get_session)): return session.exec(select(Department).order_by(Department.name.asc())).all() +@router.get("/teams", response_model=list[Team]) +def list_teams(department_id: int | None = None, session: Session = Depends(get_session)): + q = select(Team) + if department_id is not None: + q = q.where(Team.department_id == department_id) + return session.exec(q.order_by(Team.name.asc())).all() + + +@router.post("/teams", response_model=Team) +def create_team( + payload: TeamCreate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + team = Team(**payload.model_dump()) + session.add(team) + + try: + session.flush() + log_activity( + session, + actor_employee_id=actor_employee_id, + 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}, + ) + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(status_code=409, detail="Team already exists or violates constraints") + + session.refresh(team) + return team + + +@router.patch("/teams/{team_id}", response_model=Team) +def update_team( + team_id: int, + payload: TeamUpdate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + team = session.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + data = payload.model_dump(exclude_unset=True) + for k, v in data.items(): + setattr(team, k, v) + + 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) + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(status_code=409, detail="Team update violates constraints") + + session.refresh(team) + return team + + @router.post("/departments", response_model=Department) def create_department( payload: DepartmentCreate, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ed48d888..9bd9a65b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,11 +1,12 @@ from app.models.activity import Activity -from app.models.org import Department, Employee +from app.models.org import Department, Team, Employee from app.models.projects import Project, ProjectMember from app.models.work import Task, TaskComment __all__ = [ "Department", "Employee", + "Team", "Project", "ProjectMember", "Task", diff --git a/backend/app/models/org.py b/backend/app/models/org.py index f0d2caac..81a07c49 100644 --- a/backend/app/models/org.py +++ b/backend/app/models/org.py @@ -13,6 +13,16 @@ class Department(SQLModel, table=True): head_employee_id: int | None = Field(default=None, foreign_key="employees.id") +class Team(SQLModel, table=True): + __tablename__ = "teams" + + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + + department_id: int = Field(foreign_key="departments.id") + lead_employee_id: int | None = Field(default=None, foreign_key="employees.id") + + class Employee(SQLModel, table=True): __tablename__ = "employees" @@ -21,6 +31,7 @@ class Employee(SQLModel, table=True): employee_type: str # human | agent department_id: int | None = Field(default=None, foreign_key="departments.id") + team_id: int | None = Field(default=None, foreign_key="teams.id") manager_id: int | None = Field(default=None, foreign_key="employees.id") title: str | None = None diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py index acfeb15e..b079b9b2 100644 --- a/backend/app/models/projects.py +++ b/backend/app/models/projects.py @@ -10,6 +10,9 @@ class Project(SQLModel, table=True): name: str = Field(index=True, unique=True) status: str = Field(default="active") + # Project ownership: projects are assigned to teams (not departments) + team_id: int | None = Field(default=None, foreign_key="teams.id") + class ProjectMember(SQLModel, table=True): __tablename__ = "project_members" diff --git a/backend/app/schemas/org.py b/backend/app/schemas/org.py index 102872a0..0ae1d541 100644 --- a/backend/app/schemas/org.py +++ b/backend/app/schemas/org.py @@ -13,10 +13,23 @@ class DepartmentUpdate(SQLModel): head_employee_id: int | None = None +class TeamCreate(SQLModel): + name: str + department_id: int + lead_employee_id: int | None = None + + +class TeamUpdate(SQLModel): + name: str | None = None + department_id: int | None = None + lead_employee_id: int | None = None + + class EmployeeCreate(SQLModel): name: str employee_type: str department_id: int | None = None + team_id: int | None = None manager_id: int | None = None title: str | None = None status: str = "active" @@ -30,6 +43,7 @@ class EmployeeUpdate(SQLModel): name: str | None = None employee_type: str | None = None department_id: int | None = None + team_id: int | None = None manager_id: int | None = None title: str | None = None status: str | None = None diff --git a/backend/app/schemas/projects.py b/backend/app/schemas/projects.py index 98c33970..10477213 100644 --- a/backend/app/schemas/projects.py +++ b/backend/app/schemas/projects.py @@ -6,8 +6,10 @@ from sqlmodel import SQLModel class ProjectCreate(SQLModel): name: str status: str = "active" + team_id: int | None = None class ProjectUpdate(SQLModel): name: str | None = None status: str | None = None + team_id: int | None = None