From e283543ef133a239acb7a341ec7e82bd708ecb32 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 2 Feb 2026 12:51:25 +0000 Subject: [PATCH 1/2] 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 0000000..0898fad --- /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 571a489..a06309c 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 ed48d88..9bd9a65 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 f0d2caa..81a07c4 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 acfeb15..b079b9b 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 102872a..0ae1d54 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 98c3397..1047721 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 From 3d2dc7b2b170818dedfe2aff030bbb2cc0d5fc78 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 2 Feb 2026 16:53:42 +0000 Subject: [PATCH 2/2] Ensure human employee id is 1 --- ...e2c1a9a10_ensure_human_employee_id_is_1.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 backend/alembic/versions/7f4e2c1a9a10_ensure_human_employee_id_is_1.py diff --git a/backend/alembic/versions/7f4e2c1a9a10_ensure_human_employee_id_is_1.py b/backend/alembic/versions/7f4e2c1a9a10_ensure_human_employee_id_is_1.py new file mode 100644 index 0000000..caf875a --- /dev/null +++ b/backend/alembic/versions/7f4e2c1a9a10_ensure_human_employee_id_is_1.py @@ -0,0 +1,104 @@ +"""Ensure human employee id is 1 + +Revision ID: 7f4e2c1a9a10 +Revises: 3f2c1b9c8e12 +Create Date: 2026-02-02 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "7f4e2c1a9a10" +down_revision = "3f2c1b9c8e12" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """If the (single) human employee exists but is not id=1, move it to id=1. + + This is a dev ergonomics migration: a bunch of code / examples assume the + primary human user is employee_id=1. + + Safe behavior: + - Only runs when there is exactly one human employee. + - Only runs when employee id=1 is currently unused. + - Rewrites all known FKs that point at the old id. + """ + + conn = op.get_bind() + + human_ids = [ + row[0] + for row in conn.execute(sa.text("SELECT id FROM employees WHERE employee_type='human' ORDER BY id")) + ] + + # Only attempt the rewrite in the "typical dev" scenario. + if len(human_ids) != 1: + return + + old_id = int(human_ids[0]) + if old_id == 1: + return + + id1_exists = conn.execute(sa.text("SELECT 1 FROM employees WHERE id=1")).first() is not None + if id1_exists: + return + + # Update foreign keys in known tables/columns. + conn.execute( + sa.text("UPDATE departments SET head_employee_id=1 WHERE head_employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE teams SET lead_employee_id=1 WHERE lead_employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE employees SET manager_id=1 WHERE manager_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE activities SET actor_employee_id=1 WHERE actor_employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE project_members SET employee_id=1 WHERE employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE tasks SET assignee_employee_id=1 WHERE assignee_employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE tasks SET reviewer_employee_id=1 WHERE reviewer_employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE tasks SET created_by_employee_id=1 WHERE created_by_employee_id=:old_id"), + {"old_id": old_id}, + ) + conn.execute( + sa.text("UPDATE task_comments SET author_employee_id=1 WHERE author_employee_id=:old_id"), + {"old_id": old_id}, + ) + + # Finally, rewrite the employee PK itself. + conn.execute(sa.text("UPDATE employees SET id=1 WHERE id=:old_id"), {"old_id": old_id}) + + # Keep the sequence in sync (Postgres). + conn.execute( + sa.text( + "SELECT setval(pg_get_serial_sequence('employees','id'), (SELECT COALESCE(MAX(id), 1) FROM employees), true)" + ) + ) + + +def downgrade() -> None: + # Non-reversible in a safe way; this is a dev convenience migration. + pass