From 56ffd98f5ec65e1870f28a6fb86dd627f4bc76c7 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Wed, 1 Apr 2026 11:30:52 +0200 Subject: [PATCH] Phase 1 backend implementation: Add invite management endpoints Implements core invite management features for band admins: - GET /bands/{band_id}/invites - List all invites for a band (admin only) - DELETE /invites/{invite_id} - Revoke pending invite (admin only) - GET /invites/{token}/info - Get invite details (public) Backend changes: - Add invites router with 3 endpoints - Update BandRepository with get_invites_for_band and get_invite_by_id methods - Add new schemas for invite listing and info - Register invites router in main.py Tests: - Integration tests for all 3 endpoints - Permission tests (admin vs non-admin) - Edge cases (not found, expired, etc.) This addresses the core requirements: - Admins can see pending invites - Admins can revoke pending invites - Users can view invite details before accepting Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- api/src/rehearsalhub/main.py | 2 + api/src/rehearsalhub/repositories/band.py | 11 ++ api/src/rehearsalhub/routers/__init__.py | 2 + api/src/rehearsalhub/routers/bands.py | 98 +++++++++- api/src/rehearsalhub/routers/invites.py | 64 +++++++ api/src/rehearsalhub/schemas/invite.py | 38 ++++ api/tests/integration/test_api_invites.py | 209 ++++++++++++++++++++++ 7 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 api/src/rehearsalhub/routers/invites.py create mode 100644 api/tests/integration/test_api_invites.py diff --git a/api/src/rehearsalhub/main.py b/api/src/rehearsalhub/main.py index 6c4def4..290680c 100644 --- a/api/src/rehearsalhub/main.py +++ b/api/src/rehearsalhub/main.py @@ -15,6 +15,7 @@ from rehearsalhub.routers import ( annotations_router, auth_router, bands_router, + invites_router, internal_router, members_router, sessions_router, @@ -71,6 +72,7 @@ def create_app() -> FastAPI: prefix = "/api/v1" app.include_router(auth_router, prefix=prefix) app.include_router(bands_router, prefix=prefix) + app.include_router(invites_router, prefix=prefix) app.include_router(sessions_router, prefix=prefix) app.include_router(songs_router, prefix=prefix) app.include_router(versions_router, prefix=prefix) diff --git a/api/src/rehearsalhub/repositories/band.py b/api/src/rehearsalhub/repositories/band.py index eb0e9fc..dcc9004 100644 --- a/api/src/rehearsalhub/repositories/band.py +++ b/api/src/rehearsalhub/repositories/band.py @@ -81,6 +81,17 @@ class BandRepository(BaseRepository[Band]): result = await self.session.execute(stmt) return result.scalar_one_or_none() + async def get_invite_by_id(self, invite_id: uuid.UUID) -> BandInvite | None: + stmt = select(BandInvite).where(BandInvite.id == invite_id) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_invites_for_band(self, band_id: uuid.UUID) -> list[BandInvite]: + """Get all invites for a specific band.""" + stmt = select(BandInvite).where(BandInvite.band_id == band_id) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + async def get_by_nc_folder_prefix(self, path: str) -> Band | None: """Return the band whose nc_folder_path is a prefix of path.""" stmt = select(Band).where(Band.nc_folder_path.is_not(None)) diff --git a/api/src/rehearsalhub/routers/__init__.py b/api/src/rehearsalhub/routers/__init__.py index cb2773a..9240f55 100644 --- a/api/src/rehearsalhub/routers/__init__.py +++ b/api/src/rehearsalhub/routers/__init__.py @@ -1,6 +1,7 @@ from rehearsalhub.routers.annotations import router as annotations_router from rehearsalhub.routers.auth import router as auth_router from rehearsalhub.routers.bands import router as bands_router +from rehearsalhub.routers.invites import router as invites_router from rehearsalhub.routers.internal import router as internal_router from rehearsalhub.routers.members import router as members_router from rehearsalhub.routers.sessions import router as sessions_router @@ -11,6 +12,7 @@ from rehearsalhub.routers.ws import router as ws_router __all__ = [ "auth_router", "bands_router", + "invites_router", "internal_router", "members_router", "sessions_router", diff --git a/api/src/rehearsalhub/routers/bands.py b/api/src/rehearsalhub/routers/bands.py index 0190acd..0c7af89 100644 --- a/api/src/rehearsalhub/routers/bands.py +++ b/api/src/rehearsalhub/routers/bands.py @@ -1,12 +1,14 @@ import uuid +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from rehearsalhub.db.engine import get_session -from rehearsalhub.db.models import Member +from rehearsalhub.db.models import BandInvite, Member from rehearsalhub.dependencies import get_current_member from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate +from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem, InviteInfoRead from rehearsalhub.repositories.band import BandRepository from rehearsalhub.services.band import BandService from rehearsalhub.storage.nextcloud import NextcloudClient @@ -14,6 +16,100 @@ from rehearsalhub.storage.nextcloud import NextcloudClient router = APIRouter(prefix="/bands", tags=["bands"]) +@router.get("/{band_id}/invites", response_model=BandInviteList) +async def list_invites( + band_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + current_member: Member = Depends(get_current_member), +): + """List all pending invites for a band (admin only)""" + repo = BandRepository(session) + + # Check if user is admin of this band + role = await repo.get_member_role(band_id, current_member.id) + if role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required to manage invites" + ) + + # Get all invites for this band (filter by band_id) + invites = await repo.get_invites_for_band(band_id) + + # Filter for non-expired invites (optional - could also show expired) + now = datetime.now() + pending_invites = [ + invite for invite in invites + if invite.expires_at > now and invite.used_at is None + ] + total = len(invites) + pending_count = len(pending_invites) + + # Convert to response model + invite_items = [ + BandInviteListItem( + id=invite.id, + band_id=invite.band_id, + token=invite.token, + role=invite.role, + expires_at=invite.expires_at, + created_at=invite.created_at, + is_used=invite.used_at is not None, + used_at=invite.used_at, + ) + for invite in invites + ] + + return BandInviteList( + invites=invite_items, + total=total, + pending=pending_count, + ) + + +@router.delete("/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_invite( + invite_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + current_member: Member = Depends(get_current_member), +): + """Revoke a pending invite (admin only)""" + repo = BandRepository(session) + + # Get the invite + invite = await repo.get_invite_by_id(invite_id) + if invite is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invite not found" + ) + + # Check if user is admin of the band + role = await repo.get_member_role(invite.band_id, current_member.id) + if role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required to revoke invites" + ) + + # Check if invite is still pending (not used and not expired) + now = datetime.now() + if invite.used_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot revoke an already used invite" + ) + if invite.expires_at < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot revoke an expired invite" + ) + + # Revoke the invite + invite.used_at = now + await repo.session.flush() + + @router.get("", response_model=list[BandRead]) async def list_bands( session: AsyncSession = Depends(get_session), diff --git a/api/src/rehearsalhub/routers/invites.py b/api/src/rehearsalhub/routers/invites.py new file mode 100644 index 0000000..32b98a2 --- /dev/null +++ b/api/src/rehearsalhub/routers/invites.py @@ -0,0 +1,64 @@ +""" +Invite management endpoints. +""" +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from rehearsalhub.db.engine import get_session +from rehearsalhub.db.models import BandInvite, Member +from rehearsalhub.schemas.invite import InviteInfoRead +from rehearsalhub.repositories.band import BandRepository + +router = APIRouter(prefix="/invites", tags=["invites"]) + + +@router.get("/{token}/info", response_model=InviteInfoRead) +async def get_invite_info( + token: str, + session: AsyncSession = Depends(get_session), +): + """Get invite details without accepting (public endpoint)""" + repo = BandRepository(session) + + # Get the invite by token + invite = await repo.get_invite_by_token(token) + if invite is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invite not found or already used/expired" + ) + + # Check if invite is already used or expired + now = datetime.now() + if invite.used_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This invite has already been used" + ) + if invite.expires_at < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This invite has expired" + ) + + # Get band info + band = await repo.get_band_with_members(invite.band_id) + if band is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Band associated with invite not found" + ) + + return InviteInfoRead( + id=invite.id, + band_id=invite.band_id, + band_name=band.name, + band_slug=band.slug, + role=invite.role, + expires_at=invite.expires_at, + created_at=invite.created_at, + is_used=False, + ) diff --git a/api/src/rehearsalhub/schemas/invite.py b/api/src/rehearsalhub/schemas/invite.py index 99f10f8..a82a8b5 100644 --- a/api/src/rehearsalhub/schemas/invite.py +++ b/api/src/rehearsalhub/schemas/invite.py @@ -17,6 +17,44 @@ class BandInviteRead(BaseModel): used_at: datetime | None = None +class BandInviteListItem(BaseModel): + """Invite for listing (includes creator info)""" + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + band_id: uuid.UUID + token: str + role: str + expires_at: datetime + created_at: datetime + is_used: bool + used_at: datetime | None = None + # Creator info (optional, can be expanded) + + +class BandInviteList(BaseModel): + """Response for listing invites""" + model_config = ConfigDict(from_attributes=True) + + invites: list[BandInviteListItem] + total: int + pending: int + + +class InviteInfoRead(BaseModel): + """Public invite info (used for /invites/{token}/info)""" + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + band_id: uuid.UUID + band_name: str + band_slug: str + role: str + expires_at: datetime + created_at: datetime + is_used: bool + + class BandMemberRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/api/tests/integration/test_api_invites.py b/api/tests/integration/test_api_invites.py new file mode 100644 index 0000000..c236b50 --- /dev/null +++ b/api/tests/integration/test_api_invites.py @@ -0,0 +1,209 @@ +"""Integration tests for invite endpoints.""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.factories import create_band, create_member + + +@pytest.fixture +def band_with_admin(db_session): + """Create a band with an admin member.""" + admin = create_member(db_session, email="admin@test.com") + band = create_band(db_session, creator_id=admin.id) + db_session.commit() + return {"band": band, "admin": admin} + + +@pytest.fixture +def band_with_members(db_session): + """Create a band with admin and regular member.""" + admin = create_member(db_session, email="admin@test.com") + member = create_member(db_session, email="member@test.com") + band = create_band(db_session, creator_id=admin.id) + # Add member to band + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + member_role = await repo.add_member(band.id, member.id) + db_session.commit() + return {"band": band, "admin": admin, "member": member} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_list_invites_admin_can_see(client, db_session, auth_headers_for, band_with_admin): + """Test that admin can list invites for their band.""" + headers = await auth_headers_for(band_with_admin["admin"]) + band = band_with_admin["band"] + + # Create some invites + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + invite1 = await repo.create_invite(band.id, band_with_admin["admin"].id) + invite2 = await repo.create_invite(band.id, band_with_admin["admin"].id) + db_session.commit() + + resp = await client.get(f"/api/v1/bands/{band.id}/invites", headers=headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert "invites" in data + assert "total" in data + assert "pending" in data + assert data["total"] >= 2 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_list_invites_non_admin_returns_403(client, db_session, auth_headers_for, band_with_members): + """Test that non-admin cannot list invites.""" + headers = await auth_headers_for(band_with_members["member"]) + band = band_with_members["band"] + + resp = await client.get(f"/api/v1/bands/{band.id}/invites", headers=headers) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_list_invites_no_invites_returns_empty(client, db_session, auth_headers_for, band_with_admin): + """Test listing invites when none exist.""" + headers = await auth_headers_for(band_with_admin["admin"]) + band = band_with_admin["band"] + + resp = await client.get(f"/api/v1/bands/{band.id}/invites", headers=headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["invites"] == [] + assert data["total"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_list_invites_includes_pending_and_used(client, db_session, auth_headers_for, band_with_admin): + """Test that list includes both pending and used invites.""" + headers = await auth_headers_for(band_with_admin["admin"]) + band = band_with_admin["band"] + + # Create invites with different statuses + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + + # Create pending invite + pending_invite = await repo.create_invite(band.id, band_with_admin["admin"].id) + + # Create used invite (simulate by setting used_at) + used_invite = await repo.create_invite(band.id, band_with_admin["admin"].id) + used_invite.used_at = datetime.now(timezone.utc) + + db_session.commit() + + resp = await client.get(f"/api/v1/bands/{band.id}/invites", headers=headers) + assert resp.status_code == 200, resp.text + data = resp.json() + + # Check we have both invites + assert data["total"] >= 1 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_revoke_invite_admin_can_revoke(client, db_session, auth_headers_for, band_with_admin): + """Test that admin can revoke an invite.""" + headers = await auth_headers_for(band_with_admin["admin"]) + band = band_with_admin["band"] + + # Create an invite + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + invite = await repo.create_invite(band.id, band_with_admin["admin"].id) + invite_id = invite.id + db_session.commit() + + resp = await client.delete(f"/api/v1/invites/{invite_id}", headers=headers) + assert resp.status_code == 204, resp.text + + # Verify invite was revoked + resp = await client.delete(f"/api/v1/invites/{invite_id}", headers=headers) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_revoke_invite_non_admin_returns_403(client, db_session, auth_headers_for, band_with_members): + """Test that non-admin cannot revoke invites.""" + headers = await auth_headers_for(band_with_members["member"]) + band = band_with_members["band"] + + # Create an invite + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + invite = await repo.create_invite(band.id, band_with_members["admin"].id) + invite_id = invite.id + db_session.commit() + + resp = await client.delete(f"/api/v1/invites/{invite_id}", headers=headers) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_revoke_invite_not_found_returns_404(client, db_session, auth_headers_for, band_with_admin): + """Test revoking a non-existent invite.""" + headers = await auth_headers_for(band_with_admin["admin"]) + + resp = await client.delete("/api/v1/invites/00000000-0000-0000-0000-000000000000", headers=headers) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_invite_info_valid_token(client, db_session): + """Test getting invite info with valid token.""" + admin = create_member(db_session, email="admin@test.com") + band = create_band(db_session, creator_id=admin.id) + + # Create an invite + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + invite = await repo.create_invite(band.id, admin.id) + token = invite.token + db_session.commit() + + resp = await client.get(f"/api/v1/invites/{token}/info") + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["band_id"] == str(band.id) + assert data["role"] == "member" + assert data["is_used"] is False + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_invite_info_invalid_token(client): + """Test getting invite info with invalid token.""" + resp = await client.get("/api/v1/invites/invalid-token/info") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_invite_info_expired_invite(client, db_session): + """Test getting invite info for expired invite.""" + admin = create_member(db_session, email="admin@test.com") + band = create_band(db_session, creator_id=admin.id) + + # Create an invite with very short expiry + from rehearsalhub.repositories.band import BandRepository + repo = BandRepository(db_session) + invite = await repo.create_invite(band.id, admin.id, ttl_hours=0) + token = invite.token + db_session.commit() + + # Wait a bit for expiry + import asyncio + await asyncio.sleep(1) + + resp = await client.get(f"/api/v1/invites/{token}/info") + assert resp.status_code == 400