Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
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
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from fastapi import FastAPI

from app.routers import auth, formations, identity, library, roster, teams, whiteboard
from app.routers import auth, formations, identity, library, roster, suggestions, teams, whiteboard

app = FastAPI(title="Patterns of Play API")
app.include_router(auth.router)
app.include_router(teams.router)
app.include_router(whiteboard.router)
app.include_router(library.router)
app.include_router(roster.router)
app.include_router(suggestions.router)
app.include_router(formations.router)
app.include_router(identity.router)

Expand Down
32 changes: 32 additions & 0 deletions backend/app/routers/roster.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,40 @@ def _player_to_out(
dwr=player.dwr, # type: ignore[arg-type]
attributes=_attrs_to_schema(attrs),
is_you=player.user_id is not None and player.user_id == caller_user_id,
playstyle_note=player.playstyle_note,
)


def _claim_matching_row(scope: TeamScope, players: list[Player], ctx: CurrentMembership) -> None:
"""T-041: no "claim your row" surface is designed (PNGs 24/25/27 show
none), so a player's account links to their roster row implicitly
instead of through a new UI. Players are added to the roster by name
before they ever log in (Brief step 19 CRUD is coach-only), so on a
player's own GET /api/roster, if this team has exactly one row that is
both unclaimed (user_id IS NULL) and name-matches their display_name
(case/whitespace-insensitive), and they hold no other claimed row on
this team yet (doc 03 section 2: "a player user maps to at most one
roster entry per team"), that row becomes theirs.

This is the minimal linkage the suggestion flow (Brief step 22) needs
to know which row is "your own profile": a player can only submit a
suggestion, and only sees the composer/pending card, on a row they
have claimed this way. A name that matches zero or more than one row
leaves every row unclaimed exactly as before (T-033's documented gap),
so this never guesses. Idempotent and safe to call on every request:
it is a no-op once a row is claimed or when no unique match exists.
"""
if ctx.role_on_team != "player":
return
if any(p.user_id == ctx.user.id for p in players):
return
target = ctx.user.display_name.strip().casefold()
matches = [p for p in players if p.user_id is None and p.name.strip().casefold() == target]
if len(matches) == 1:
matches[0].user_id = ctx.user.id
scope.commit()


def _compute_fit_warnings(players: list[Player], clash: RoleClash | None) -> list[FitWarningOut]:
"""Doc 03 section 3: "a roster flank where the wide player has AWR high
and DWR low, and the fullback or wingback behind them on the same side
Expand Down Expand Up @@ -180,6 +211,7 @@ def get_roster(
db: Session = Depends(get_db),
) -> dict:
players = scope.query(Player).order_by(Player.jersey_number.asc().nulls_last()).all()
_claim_matching_row(scope, players, ctx)
attrs_by_player = _attrs_by_player(scope)
roles = _role_map(db)
player_outs = [
Expand Down
190 changes: 190 additions & 0 deletions backend/app/routers/suggestions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Player playstyle suggestion flow (doc 03 section 3 playstyle_suggestions;
Brief step 22, PNG 24/25/27; T-041).

README roles table: "Suggest own playstyle: Not applicable (coach); Yes
(player): free text on own profile, then pending coach review; coach sees a
gold badge on the row and an Approve / Dismiss card. Approve merges the note
into the profile." Every route depends on get_team_scope (team_id always
comes from the caller's own membership, never a client-supplied field,
CLAUDE.md rule 4) and enforces role at the API, not just the UI (CLAUDE.md
rule 5): only a player may submit a suggestion, and only against their own
linked roster row (app/routers/roster.py's claim-by-name-match); only a
coach may list the team's pending queue, approve, or dismiss. A player
calling either of the coach-only endpoints gets 403, proven in
backend/tests/test_suggestions_routes.py.
"""

from fastapi import APIRouter, Depends, HTTPException, status

from app.deps import CurrentMembership, get_current_membership, require_role_on_team
from app.models import Player, PlaystyleSuggestion
from app.models._util import utcnow
from app.schemas import SuggestionCreateRequest, SuggestionOut
from app.scoped import TeamScope, get_team_scope

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


def _suggestion_to_out(suggestion: PlaystyleSuggestion, player_name: str) -> SuggestionOut:
return SuggestionOut(
id=suggestion.id,
player_id=suggestion.player_id,
player_name=player_name,
author_user_id=suggestion.author_user_id,
text=suggestion.text,
status=suggestion.status, # type: ignore[arg-type]
created_at=suggestion.created_at,
reviewed_at=suggestion.reviewed_at,
)


# ---------------------------------------------------------------------------
# Player-facing: submit and read back the suggestions for one roster row.
# ---------------------------------------------------------------------------


@router.post(
"/players/{player_id}/suggestions",
response_model=SuggestionOut,
status_code=status.HTTP_201_CREATED,
)
def submit_suggestion(
player_id: int,
payload: SuggestionCreateRequest,
ctx: CurrentMembership = Depends(require_role_on_team("player")),
scope: TeamScope = Depends(get_team_scope),
) -> SuggestionOut:
# require_role_on_team("player") already 403s a coach caller (README:
# "Suggest own playstyle" is "Not applicable" for coaches).
player = scope.get(Player, player_id)
if player is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Player not found")
if player.user_id != ctx.user.id:
# README: "free text on own profile" -- never against a teammate's
# row, even one on the same team.
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")

existing_pending = (
scope.query(PlaystyleSuggestion)
.filter(
PlaystyleSuggestion.player_id == player_id,
PlaystyleSuggestion.status == "pending",
)
.first()
)
if existing_pending is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A suggestion is already pending coach review",
)

suggestion = PlaystyleSuggestion(
player_id=player_id,
author_user_id=ctx.user.id,
text=payload.text,
status="pending",
)
scope.add(suggestion)
scope.commit()
scope.refresh(suggestion)
return _suggestion_to_out(suggestion, player.name)


@router.get("/players/{player_id}/suggestions", response_model=list[SuggestionOut])
def list_player_suggestions(
player_id: int,
ctx: CurrentMembership = Depends(get_current_membership),
scope: TeamScope = Depends(get_team_scope),
) -> list[SuggestionOut]:
player = scope.get(Player, player_id)
if player is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Player not found")
if ctx.role_on_team != "coach" and player.user_id != ctx.user.id:
# A player may read back their own suggestion history; a coach may
# read any row on their team (needed to render the review card).
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")

rows = (
scope.query(PlaystyleSuggestion)
.filter(PlaystyleSuggestion.player_id == player_id)
.order_by(PlaystyleSuggestion.created_at.desc())
.all()
)
return [_suggestion_to_out(row, player.name) for row in rows]


# ---------------------------------------------------------------------------
# Coach-facing: the team-wide pending queue, approve, dismiss. All three
# 403 a player caller (README: fit warnings and suggestion review are
# coach-only capabilities; CLAUDE.md rule 5).
# ---------------------------------------------------------------------------


@router.get("/suggestions/pending", response_model=list[SuggestionOut])
def list_pending_suggestions(
ctx: CurrentMembership = Depends(require_role_on_team("coach")),
scope: TeamScope = Depends(get_team_scope),
) -> list[SuggestionOut]:
rows = (
scope.query(PlaystyleSuggestion)
.filter(PlaystyleSuggestion.status == "pending")
.order_by(PlaystyleSuggestion.created_at.desc())
.all()
)
player_ids = {row.player_id for row in rows}
players = {
p.id: p for p in scope.query(Player).filter(Player.id.in_(player_ids)).all()
}
return [_suggestion_to_out(row, players[row.player_id].name) for row in rows]


def _pending_or_409(scope: TeamScope, suggestion_id: int) -> PlaystyleSuggestion:
suggestion = scope.get(PlaystyleSuggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Suggestion not found")
if suggestion.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Suggestion already reviewed"
)
return suggestion


@router.post("/suggestions/{suggestion_id}/approve", response_model=SuggestionOut)
def approve_suggestion(
suggestion_id: int,
ctx: CurrentMembership = Depends(require_role_on_team("coach")),
scope: TeamScope = Depends(get_team_scope),
) -> SuggestionOut:
suggestion = _pending_or_409(scope, suggestion_id)
player = scope.get(Player, suggestion.player_id)
if player is None: # pragma: no cover - FK guarantees this in practice
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Player not found")

suggestion.status = "approved"
suggestion.reviewed_at = utcnow()
suggestion.reviewed_by = ctx.user.id
# README: "Approve merges the note into the profile."
player.playstyle_note = suggestion.text
scope.commit()
scope.refresh(suggestion)
return _suggestion_to_out(suggestion, player.name)


@router.post("/suggestions/{suggestion_id}/dismiss", response_model=SuggestionOut)
def dismiss_suggestion(
suggestion_id: int,
ctx: CurrentMembership = Depends(require_role_on_team("coach")),
scope: TeamScope = Depends(get_team_scope),
) -> SuggestionOut:
suggestion = _pending_or_409(scope, suggestion_id)
player = scope.get(Player, suggestion.player_id)
player_name = player.name if player is not None else ""

# README: "dismiss clears it" -- status flips with no merge into
# playstyle_note, so the profile is unchanged.
suggestion.status = "dismissed"
suggestion.reviewed_at = utcnow()
suggestion.reviewed_by = ctx.user.id
scope.commit()
scope.refresh(suggestion)
return _suggestion_to_out(suggestion, player_name)
49 changes: 46 additions & 3 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,15 @@ class PlayerOut(BaseModel):
dwr: WorkRate
attributes: PlayerAttributesIn
# True when this row belongs to the calling user (README roles table:
# player's "own row marked (you)"). Always false until a roster row is
# claimed by a player account, which is out of this ticket's scope
# (see T-033 final report).
# player's "own row marked (you)"). Set by app/routers/roster.py's
# claim-by-name-match (T-041; see that module's docstring) once a
# player's display_name uniquely matches an unclaimed row.
is_you: bool
# doc 03 section 3: "approved text merges into players.playstyle_note".
# Visible to both roles (it is part of the player's profile, not
# coach-only data like fit_warnings/receipts): README "Approve merges
# the note into the profile."
playstyle_note: str | None = None


class FitWarningOut(BaseModel):
Expand Down Expand Up @@ -336,6 +341,44 @@ class CoachRosterOut(RosterOut):
fit_warnings: list[FitWarningOut]


# ---------------------------------------------------------------------------
# Playstyle suggestions (doc 03 section 3 playstyle_suggestions; Brief step
# 22, PNG 24/25/27; T-041). README roles table: a player suggests a change
# to their own playstyle as free text; it sits "pending coach review" until
# a coach approves (merging the text into players.playstyle_note above) or
# dismisses it (clearing it with no merge).
# ---------------------------------------------------------------------------

SuggestionStatus = Literal["pending", "approved", "dismissed"]


class SuggestionCreateRequest(BaseModel):
"""No player_id, author_user_id, team_id, or status field on purpose
(CLAUDE.md rule 4 / doc 03 4.2 author-stamping precedent): player_id
comes from the path, author_user_id and team_id are stamped server-side
from the session, and a freshly submitted suggestion is always
'pending'."""

model_config = ConfigDict(extra="forbid")

text: str = Field(min_length=1, max_length=2000)


class SuggestionOut(BaseModel):
id: int
player_id: int
# Resolved server-side (same pattern as PlayerOut.role_name /
# SavedPatternOut.author_label) so the frontend never re-derives it and
# the coach's pending-review list can render a name without a second
# round trip per row.
player_name: str
author_user_id: int
text: str
status: SuggestionStatus
created_at: datetime
reviewed_at: datetime | None


# ---------------------------------------------------------------------------
# Formations, keystones, rondo map (doc 03 section 5, Bible 4/3G.2; Brief
# step 18; T-032). Library-world content like LibraryItemOut above: no
Expand Down
Loading
Loading