Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cec879c
chore(backlog): T-011 done
BrandanBurgess Jul 16, 2026
d0a1e4f
docs(agent): orchestrator state snapshot for session handoff
BrandanBurgess Jul 16, 2026
613cad2
feat(screens): whiteboard page with toolbar, view menu, record/save (…
BrandanBurgess Jul 16, 2026
b5d7160
merge: T-030 whiteboard page into integration
BrandanBurgess Jul 16, 2026
24ccc96
chore(backlog): T-030 done, T-031 + T-033 doing
BrandanBurgess Jul 16, 2026
23e21ff
feat(screens): patterns page with libraries, chips, search, details (…
BrandanBurgess Jul 16, 2026
880efb4
merge: T-031 patterns page into integration
BrandanBurgess Jul 16, 2026
733ac3f
feat(screens): roster page with CRUD, sliders, double-exposure warnin…
BrandanBurgess Jul 16, 2026
598c97a
fix(infra): seed database in e2e boot path so fresh environments pass…
BrandanBurgess Jul 16, 2026
b165909
merge: T-031 seed-on-boot fix into integration
BrandanBurgess Jul 16, 2026
09c9fe2
merge: reconcile main squash history into integration
BrandanBurgess Jul 16, 2026
ea0930f
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
7fd5a3a
merge: T-033 roster page into integration (resolve nav shell, schemas…
BrandanBurgess Jul 16, 2026
add1c38
chore(backlog): T-031 done, T-033 pr, T-032 + T-034 doing
BrandanBurgess Jul 16, 2026
e8926a7
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
f331829
chore(backlog): T-033 done
BrandanBurgess Jul 16, 2026
c634850
feat(screens): formations page with keystones, keycards, rondo map (T…
BrandanBurgess Jul 16, 2026
877ad0d
merge: T-032 formations page into integration
BrandanBurgess Jul 16, 2026
35a07a2
chore(e2e): drop duplicated nav assertion left by merge
BrandanBurgess Jul 16, 2026
ce9695c
feat(screens): identity page with reference teams, pass-risk, cult co…
BrandanBurgess Jul 16, 2026
62c8b24
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
02cf1c3
merge: T-034 identity page into integration (all five nav entries live)
BrandanBurgess Jul 16, 2026
f8fde64
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
4d7ff05
chore(backlog): T-032 + T-034 done, screens phase complete
BrandanBurgess Jul 16, 2026
d1ce7c1
chore(backlog): T-040 + T-041 doing
BrandanBurgess Jul 16, 2026
abb4823
feat(collab): role gating suite, API enforcement audit (T-040)
BrandanBurgess Jul 16, 2026
817df6d
merge: T-040 role gating suite into integration
BrandanBurgess Jul 16, 2026
8732131
chore(backlog): founder decisions recorded, add T-012 + T-043
BrandanBurgess Jul 16, 2026
0151d7b
feat(collab): playstyle suggestion flow (T-041)
BrandanBurgess Jul 16, 2026
27c9211
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
2131cc4
merge: T-041 suggestion flow into integration
BrandanBurgess Jul 16, 2026
0e4cf0c
chore(backlog): T-041 pr, T-012 doing
BrandanBurgess Jul 16, 2026
302040d
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
7f69ea2
chore(backlog): T-041 done
BrandanBurgess Jul 16, 2026
ea71eae
feat(platform): role-scoped join codes, head coach member management …
BrandanBurgess Jul 16, 2026
0282683
merge: T-043 team management into integration
BrandanBurgess Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/app/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,20 @@ def _dependency(
return ctx

return _dependency


def require_head_coach(
ctx: CurrentMembership = Depends(get_current_membership),
) -> CurrentMembership:
"""T-043 decision 3: head-coach-only routes (remove a member, change a
member's role_on_team). The head coach is the team's CREATOR
(Team.created_by), a separate concept from role_on_team, so this is
its own dependency rather than another require_role_on_team() value:
a non-creator coach and a player both get 403 here, on exactly the
same terms (CLAUDE.md rule 5: enforced in the API, independent of
which role_on_team the caller otherwise holds)."""
if ctx.team.created_by != ctx.user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Only the head coach can do this"
)
return ctx
13 changes: 13 additions & 0 deletions backend/app/models/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,20 @@ class Team(Base):
colors_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
age_group: Mapped[str | None] = mapped_column(String(50), nullable=True)
level: Mapped[str | None] = mapped_column(String(50), nullable=True)
# T-043 (founder decision 2026-07-16): role-scoped join codes. Two
# codes per team now, each resolving to a fixed role_on_team for
# whoever joins with it, independent of that account's own global
# `role`. `join_code` is the ORIGINAL column from T-003 (doc 03
# section 2), REPURPOSED in place rather than renamed or replaced, so
# every pre-existing team's code and every row referencing it survive
# the migration untouched: it is now specifically the PLAYER code.
# `coach_join_code` is new (migration 0004), generated the same way,
# and its uniqueness is checked against BOTH columns (see
# app/routers/teams.py _unique_code) so a submitted code can never
# match a row in one column and a different row in the other: any
# code that validates resolves to exactly one team and one role.
join_code: Mapped[str] = mapped_column(String(12), unique=True, index=True, nullable=False)
coach_join_code: Mapped[str] = mapped_column(String(12), unique=True, index=True, nullable=False)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
Expand Down
46 changes: 35 additions & 11 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
from app.config import COOKIE_SECURE, JWT_TTL_SECONDS, SESSION_COOKIE_NAME
from app.deps import get_current_user_optional, get_db
from app.models import TeamMember, User
from app.schemas import LoginRequest, MeOut, MembershipOut, RegisterRequest, TeamOut, UserOut
from app.schemas import (
CoachMembershipOut,
CoachTeamOut,
LoginRequest,
MembershipOut,
RegisterRequest,
TeamOut,
UserOut,
)
from app.security import hash_password, verify_password
from app.tokens import create_session_token

Expand Down Expand Up @@ -72,28 +80,44 @@ def logout(response: Response) -> dict[str, bool]:
return {"ok": True}


@router.get("/me", response_model=MeOut)
@router.get("/me", response_model=None)
def me(
current_user: User | None = Depends(get_current_user_optional),
db: Session = Depends(get_db),
) -> MeOut:
) -> dict:
# response_model=None (see schemas.py MeOut docstring): each
# membership below is dumped through MembershipOut or
# CoachMembershipOut individually, picked per-row by that row's own
# role_on_team, so a coach's own team(s) carry both join codes and a
# player's carry neither key at all (T-043 decision 2). A single
# shared response_model cannot express that per-row split.
if current_user is None:
return MeOut(user=None, memberships=[])
return {"user": None, "memberships": []}

memberships = (
db.query(TeamMember)
.filter(TeamMember.user_id == current_user.id)
.order_by(TeamMember.joined_at.asc())
.all()
)
return MeOut(
user=UserOut.model_validate(current_user),
memberships=[
MembershipOut(
membership_outs = []
for m in memberships:
membership_out: MembershipOut | CoachMembershipOut
if m.role_on_team == "coach":
membership_out = CoachMembershipOut(
team=CoachTeamOut.model_validate(m.team),
role_on_team=m.role_on_team, # type: ignore[arg-type]
joined_at=m.joined_at,
)
else:
membership_out = MembershipOut(
team=TeamOut.model_validate(m.team),
role_on_team=m.role_on_team, # type: ignore[arg-type]
joined_at=m.joined_at,
)
for m in memberships
],
)
membership_outs.append(membership_out.model_dump(mode="json"))

return {
"user": UserOut.model_validate(current_user).model_dump(mode="json"),
"memberships": membership_outs,
}
189 changes: 172 additions & 17 deletions backend/app/routers/teams.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,69 @@
"""Team creation, role-scoped join, and head-coach member management
(doc 03 section 2; T-043, founder decision 2026-07-16).

Join codes: a team carries two, `join_code` (the player code, doc 03's
original column, repurposed in place) and `coach_join_code` (added by
migration 0004). Joining with a code assigns THAT code's role on the
team, never the joiner's own account `role` (app/models/User.role is only
ever consulted for team CREATION below, exactly as before). Both codes
are coach-only in every response shape here: CoachTeamOut is built only
when the caller's own role_on_team (post-join, for join_team; from the
resolved membership, for current_team; from the just-created coach
membership, for create_team) is "coach"; a player-shaped response uses
plain TeamOut, which has no join-code field at all (see schemas.py).

Head-coach member management: the head coach is the team's creator
(Team.created_by). require_head_coach (app/deps.py) 403s anyone else,
coach or player, on the two mutation routes below. The plain member list
is visible to any coach (require_role_on_team("coach")), same as the
Brief section 3 "Roster ... Full" coach capability shape, but 403s a
player outright: there is no view for this that a player is meant to see
at all (no PNG exists for this ticket; Brief section 8: build the
smallest honest surface, not an invented one).
"""

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import or_
from sqlalchemy.orm import Session

from app.deps import CurrentMembership, get_current_membership, get_current_user, get_db
from app.deps import (
CurrentMembership,
get_current_membership,
get_current_user,
get_db,
require_head_coach,
require_role_on_team,
)
from app.models import Team, TeamMember, User
from app.schemas import TeamCreateRequest, TeamJoinRequest, TeamOut
from app.schemas import (
CoachTeamOut,
TeamCreateRequest,
TeamJoinRequest,
TeamMemberOut,
TeamMemberRoleUpdateRequest,
TeamOut,
)
from app.scoped import TeamScope, get_team_scope
from app.security import generate_join_code

router = APIRouter(prefix="/api/teams", tags=["teams"])

_JOIN_CODE_ATTEMPTS = 20


def _unique_join_code(db: Session) -> str:
def _unique_code(db: Session) -> str:
"""A candidate must be free in BOTH `join_code` and `coach_join_code`
across every team: this is what guarantees a submitted code resolves
unambiguously to exactly one (team, role) pair in join_team below,
doc 03 section 2's single-namespace uniqueness extended to two
namespaces that must never overlap."""
for _ in range(_JOIN_CODE_ATTEMPTS):
candidate = generate_join_code()
taken = db.query(Team.id).filter(Team.join_code == candidate).first()
taken = (
db.query(Team.id)
.filter(or_(Team.join_code == candidate, Team.coach_join_code == candidate))
.first()
)
if taken is None:
return candidate
# Astronomically unlikely at pilot scale (6 chars, 32-symbol alphabet).
Expand All @@ -24,12 +73,25 @@ def _unique_join_code(db: Session) -> str:
)


@router.post("", response_model=TeamOut, status_code=status.HTTP_201_CREATED)
def _team_out(team: Team, role_on_team: str) -> dict:
"""The one place that decides TeamOut vs CoachTeamOut. response_model
is None on every route that calls this (same reasoning as
app/routers/roster.py get_roster): a player-shaped dict must have no
join_code/coach_join_code KEY at all, not a null one, and a shared
response_model would either drop CoachTeamOut's extra fields (if
typed TeamOut) or backfill them as null onto a player payload (if
typed CoachTeamOut) depending on which way the coercion ran."""
if role_on_team == "coach":
return CoachTeamOut.model_validate(team).model_dump(mode="json")
return TeamOut.model_validate(team).model_dump(mode="json")


@router.post("", response_model=None, status_code=status.HTTP_201_CREATED)
def create_team(
payload: TeamCreateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Team:
) -> dict:
# Team creation is a coach action. This checks the account's global
# role because no team_members row exists yet to carry role_on_team;
# every route created afterward scopes off get_current_membership
Expand All @@ -39,31 +101,49 @@ def create_team(
status_code=status.HTTP_403_FORBIDDEN, detail="Only coaches can create a team"
)

# Both codes are picked before the team row exists at all, so no
# not-null column is ever briefly unset across a flush: `_unique_code`
# only sees committed/flushed rows, so the two calls could in
# principle agree (astronomically unlikely, 6 chars over a 32-symbol
# alphabet) without the explicit != check below.
join_code = _unique_code(db)
coach_join_code = _unique_code(db)
while coach_join_code == join_code:
coach_join_code = _unique_code(db)

team = Team(
name=payload.name,
age_group=payload.age_group,
level=payload.level,
colors_json=payload.colors_json,
created_by=current_user.id,
join_code=_unique_join_code(db),
join_code=join_code,
coach_join_code=coach_join_code,
)
db.add(team)
db.flush() # assigns team.id for the membership row below

db.add(TeamMember(team_id=team.id, user_id=current_user.id, role_on_team="coach"))
db.commit()
db.refresh(team)
return team
# The creator is the head coach, always a coach member: always the
# coach-shaped payload, never built from ctx (none exists yet here).
return _team_out(team, "coach")


@router.post("/join", response_model=TeamOut)
@router.post("/join", response_model=None)
def join_team(
payload: TeamJoinRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Team:
) -> dict:
code = payload.join_code.strip().upper()

role_on_team = "player"
team = db.query(Team).filter(Team.join_code == code).first()
if team is None:
team = db.query(Team).filter(Team.coach_join_code == code).first()
role_on_team = "coach"
if team is None:
# Wrong code fails cleanly: 404, no hint about which part is wrong,
# no stack trace, no partial state written.
Expand All @@ -79,16 +159,91 @@ def join_team(
status_code=status.HTTP_409_CONFLICT, detail="Already a member of this team"
)

db.add(
TeamMember(team_id=team.id, user_id=current_user.id, role_on_team=current_user.role)
)
# role_on_team comes ENTIRELY from which code column matched above
# (T-043 decision 1): current_user.role (the account's own global
# role) never decides anything here, unlike the pre-T-043 behavior.
db.add(TeamMember(team_id=team.id, user_id=current_user.id, role_on_team=role_on_team))
db.commit()
db.refresh(team)
return team
return _team_out(team, role_on_team)


@router.get("/current", response_model=TeamOut)
def current_team(ctx: CurrentMembership = Depends(get_current_membership)) -> Team:
@router.get("/current", response_model=None)
def current_team(ctx: CurrentMembership = Depends(get_current_membership)) -> dict:
# team_id comes from the caller's own membership row, never from a
# client-supplied parameter (CLAUDE.md rule 4).
return ctx.team
return _team_out(ctx.team, ctx.role_on_team)


# ---------------------------------------------------------------------------
# Head-coach member management (T-043 decision 3). The list itself is any
# coach's to view; the mutations are the creator's alone (require_head_coach).
# ---------------------------------------------------------------------------


def _member_to_out(member: TeamMember, user: User, created_by: int) -> TeamMemberOut:
return TeamMemberOut(
id=member.id,
user_id=member.user_id,
display_name=user.display_name,
role_on_team=member.role_on_team, # type: ignore[arg-type]
is_head_coach=member.user_id == created_by,
joined_at=member.joined_at,
)


@router.get("/members", response_model=list[TeamMemberOut])
def list_members(
ctx: CurrentMembership = Depends(require_role_on_team("coach")),
scope: TeamScope = Depends(get_team_scope),
db: Session = Depends(get_db),
) -> list[TeamMemberOut]:
members = scope.query(TeamMember).order_by(TeamMember.joined_at.asc()).all()
users = {
u.id: u
for u in db.query(User).filter(User.id.in_([m.user_id for m in members])).all()
}
return [_member_to_out(m, users[m.user_id], ctx.team.created_by) for m in members]


@router.delete("/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_member(
member_id: int,
ctx: CurrentMembership = Depends(require_head_coach),
scope: TeamScope = Depends(get_team_scope),
) -> None:
member = scope.get(TeamMember, member_id)
if member is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
if member.user_id == ctx.user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot remove yourself from the team",
)
scope.delete(member)
scope.commit()


@router.patch("/members/{member_id}/role", response_model=TeamMemberOut)
def update_member_role(
member_id: int,
payload: TeamMemberRoleUpdateRequest,
ctx: CurrentMembership = Depends(require_head_coach),
scope: TeamScope = Depends(get_team_scope),
db: Session = Depends(get_db),
) -> TeamMemberOut:
member = scope.get(TeamMember, member_id)
if member is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
if member.user_id == ctx.user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="You cannot change your own role"
)

member.role_on_team = payload.role_on_team
scope.commit()
scope.refresh(member)

user = db.get(User, member.user_id)
assert user is not None # FK guarantees this
return _member_to_out(member, user, ctx.team.created_by)
Loading
Loading