Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions backend/app/api/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
class CreateGroupIn(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str | None = None
member_participant_ids: list[uuid.UUID] = Field(default_factory=list, max_length=100)


class PatchGroupIn(BaseModel):
Expand Down Expand Up @@ -494,6 +495,7 @@ async def create_group(
creator_participant_id=participant.id,
name=body.name,
description=body.description,
member_participant_ids=body.member_participant_ids,
)
except GroupChatServiceError as exc:
raise _translate_domain_error(exc) from exc
Expand All @@ -503,6 +505,11 @@ async def create_group(
action="group:create",
tenant_id=tenant_id,
group_id=group.id,
details={
"member_participant_ids": [
str(participant_id) for participant_id in body.member_participant_ids
]
},
)
return group

Expand All @@ -521,6 +528,32 @@ async def list_groups(
)


# Registered before "/{group_id}" so the literal path is not parsed as a group id.
@router.get("/member-candidates", response_model=list[GroupMemberCandidateOut])
async def list_tenant_member_candidates(
participant_type: Annotated[Literal["user", "agent"], Query()],
limit: Annotated[int, Query(ge=1, le=100)] = 100,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Candidates for the create-group flow, before any group exists."""
tenant_id = _tenant_id(current_user)
try:
candidates = await group_chat_service.list_tenant_member_candidates(
db,
tenant_id=tenant_id,
actor_user=current_user,
participant_type=participant_type,
limit=limit,
)
except GroupChatServiceError as exc:
raise _translate_domain_error(exc) from exc
return [
GroupMemberCandidateOut.model_validate(candidate, from_attributes=True)
for candidate in candidates
]


@router.get("/{group_id}", response_model=GroupOut)
async def get_group(
group_id: uuid.UUID,
Expand Down
171 changes: 142 additions & 29 deletions backend/app/services/group_chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import uuid
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime

Expand Down Expand Up @@ -164,6 +165,64 @@ async def _active_membership(
return membership


async def _human_actor_user(
db: AsyncSession,
*,
tenant_id: uuid.UUID,
actor: Participant,
) -> User:
"""Resolve the active tenant user behind a validated human participant."""
result = await db.execute(
select(User).where(
User.id == actor.ref_id,
User.tenant_id == tenant_id,
User.is_active.is_(True),
)
)
actor_user = result.scalar_one_or_none()
if actor_user is None:
raise GroupChatServiceError(
"group_human_member_required",
"An active human group member is required",
)
return actor_user


async def _invitable_participant(
db: AsyncSession,
*,
tenant_id: uuid.UUID,
actor: Participant,
participant_id: uuid.UUID,
) -> Participant:
"""Validate an invite target, including Agent visibility for the inviter."""
target = await _valid_participant(
db,
tenant_id=tenant_id,
participant_id=participant_id,
human_only=False,
error_code="group_participant_invalid",
)
if target.type != "agent":
return target

# Resolved only for Agent targets, where inviter visibility must be checked.
actor_user = await _human_actor_user(db, tenant_id=tenant_id, actor=actor)
target_agent_result = await db.execute(
select(Agent).where(
Agent.id == target.ref_id,
Agent.tenant_id == tenant_id,
)
)
target_agent = target_agent_result.scalar_one_or_none()
if target_agent is None or not await can_use_agent(db, actor_user, target_agent):
raise GroupChatServiceError(
"group_participant_invalid",
"Agent is not visible to the inviting member",
)
return target


async def _human_actor(
db: AsyncSession,
*,
Expand Down Expand Up @@ -321,6 +380,7 @@ async def create_group(
creator_participant_id: uuid.UUID,
name: str,
description: str | None = None,
member_participant_ids: Sequence[uuid.UUID] = (),
) -> Group:
"""Create a group and its initial manager without owning the transaction."""
normalized_name = _required_text(
Expand All @@ -329,14 +389,30 @@ async def create_group(
field="name",
max_length=200,
)
await _valid_participant(
creator = await _valid_participant(
db,
tenant_id=tenant_id,
participant_id=creator_participant_id,
human_only=True,
error_code="group_creator_invalid",
)

invited_ids: list[uuid.UUID] = []
seen_ids = {creator_participant_id}
for participant_id in member_participant_ids:
if participant_id in seen_ids:
continue
seen_ids.add(participant_id)
invited_ids.append(participant_id)

for participant_id in invited_ids:
await _invitable_participant(
db,
tenant_id=tenant_id,
actor=creator,
participant_id=participant_id,
)

now = _now()
group = Group(
id=uuid.uuid4(),
Expand All @@ -359,6 +435,18 @@ async def create_group(
)
db.add(group)
db.add(creator_membership)
for participant_id in invited_ids:
db.add(
GroupMember(
id=uuid.uuid4(),
group_id=group.id,
participant_id=participant_id,
role="member",
joined_at=now,
removed_at=None,
session_read_state={},
)
)
await db.flush()
return group

Expand Down Expand Up @@ -518,6 +606,57 @@ async def list_group_member_candidates(
)
active_ref_ids = set(active_refs_result.scalars().all())

return await _member_candidates(
db,
tenant_id=tenant_id,
actor_user=actor_user,
participant_type=participant_type,
limit=limit,
excluded_ref_ids=active_ref_ids,
)


async def list_tenant_member_candidates(
db: AsyncSession,
*,
tenant_id: uuid.UUID,
actor_user: User,
participant_type: str,
limit: int,
) -> tuple[GroupMemberCandidate, ...]:
"""List inviteable identities before a group exists, for the create flow."""
if participant_type not in {"user", "agent"}:
raise GroupChatServiceError(
"group_participant_type_invalid",
"Participant type must be 'user' or 'agent'",
)
if actor_user.tenant_id != tenant_id or not actor_user.is_active:
raise GroupChatServiceError(
"group_human_member_required",
"An active human group member is required",
)

# The creator joins as manager on create, so never offer them as a candidate.
return await _member_candidates(
db,
tenant_id=tenant_id,
actor_user=actor_user,
participant_type=participant_type,
limit=limit,
excluded_ref_ids={actor_user.id} if participant_type == "user" else set(),
)


async def _member_candidates(
db: AsyncSession,
*,
tenant_id: uuid.UUID,
actor_user: User,
participant_type: str,
limit: int,
excluded_ref_ids: set[uuid.UUID],
) -> tuple[GroupMemberCandidate, ...]:
active_ref_ids = excluded_ref_ids
candidates: list[GroupMemberCandidate] = []
if participant_type == "user":
statement = select(User).where(
Expand Down Expand Up @@ -598,38 +737,12 @@ async def invite_group_member(
participant_id=actor_participant_id,
manager_only=False,
)
target = await _valid_participant(
await _invitable_participant(
db,
tenant_id=tenant_id,
actor=actor,
participant_id=participant_id,
human_only=False,
error_code="group_participant_invalid",
)
if target.type == "agent":
actor_user_result = await db.execute(
select(User).where(
User.id == actor.ref_id,
User.tenant_id == tenant_id,
User.is_active.is_(True),
)
)
actor_user = actor_user_result.scalar_one_or_none()
target_agent_result = await db.execute(
select(Agent).where(
Agent.id == target.ref_id,
Agent.tenant_id == tenant_id,
)
)
target_agent = target_agent_result.scalar_one_or_none()
if (
actor_user is None
or target_agent is None
or not await can_use_agent(db, actor_user, target_agent)
):
raise GroupChatServiceError(
"group_participant_invalid",
"Agent is not visible to the inviting member",
)

existing_result = await db.execute(
select(GroupMember)
Expand Down
47 changes: 46 additions & 1 deletion backend/tests/test_group_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def test_group_router_exposes_management_and_read_state_boundaries() -> None:
assert ("POST", "/api/groups") in routes
assert ("GET", "/api/groups/{group_id}/members") in routes
assert ("GET", "/api/groups/{group_id}/member-candidates") in routes
assert ("GET", "/api/groups/member-candidates") in routes
assert ("POST", "/api/groups/{group_id}/sessions") in routes
assert ("DELETE", "/api/groups/{group_id}/sessions/{session_id}") in routes
assert ("POST", "/api/groups/{group_id}/sessions/{session_id}/read") in routes
Expand All @@ -115,6 +116,13 @@ def test_group_router_exposes_management_and_read_state_boundaries() -> None:
assert ("PATCH", "/api/groups/{group_id}/members/{member_id}") not in routes


def test_tenant_member_candidates_is_matched_before_the_group_id_route() -> None:
"""A literal path after "/{group_id}" would be parsed as a group id and 422."""
paths = [getattr(route, "path", None) for route in groups_api.router.routes]

assert paths.index("/api/groups/member-candidates") < paths.index("/api/groups/{group_id}")


def test_group_invite_write_contract_only_accepts_participant_id() -> None:
assert set(groups_api.InviteGroupMemberIn.model_fields) == {"participant_id"}

Expand Down Expand Up @@ -255,14 +263,51 @@ async def fake_create(_db, **kwargs):
"creator_participant_id": participant.id,
"name": "Runtime Group",
"description": None,
"member_participant_ids": [],
}
]
assert len(db.added) == 1
audit = db.added[0]
assert isinstance(audit, AuditLog)
assert audit.action == "group:create"
assert audit.user_id == user.id
assert audit.details == {"tenant_id": str(tenant_id), "group_id": str(group.id)}
assert audit.details == {
"tenant_id": str(tenant_id),
"group_id": str(group.id),
"member_participant_ids": [],
}


@pytest.mark.asyncio
async def test_create_group_forwards_initial_members_and_audits_them(monkeypatch) -> None:
tenant_id = uuid.uuid4()
user = _user(tenant_id)
participant = _participant(user)
group = _group(tenant_id, participant.id)
invited = [uuid.uuid4(), uuid.uuid4()]
db = _RecordingDB()
calls = []

async def fake_participant(_db, current_user):
return participant

async def fake_create(_db, **kwargs):
calls.append(kwargs)
return group

monkeypatch.setattr(groups_api, "_current_participant", fake_participant)
monkeypatch.setattr(groups_api.group_chat_service, "create_group", fake_create)

result = await groups_api.create_group(
groups_api.CreateGroupIn(name="Runtime Group", member_participant_ids=invited),
current_user=user,
db=db,
)

assert result is group
assert calls[0]["member_participant_ids"] == invited
audit = db.added[0]
assert audit.details["member_participant_ids"] == [str(value) for value in invited]


@pytest.mark.asyncio
Expand Down
Loading