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 <vibe@mistral.ai>
This commit is contained in:
Mistral Vibe
2026-04-01 11:30:52 +02:00
parent ce228919df
commit 56ffd98f5e
7 changed files with 423 additions and 1 deletions

View File

@@ -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)

View File

@@ -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))

View File

@@ -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",

View File

@@ -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),

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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