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, annotations_router,
auth_router, auth_router,
bands_router, bands_router,
invites_router,
internal_router, internal_router,
members_router, members_router,
sessions_router, sessions_router,
@@ -71,6 +72,7 @@ def create_app() -> FastAPI:
prefix = "/api/v1" prefix = "/api/v1"
app.include_router(auth_router, prefix=prefix) app.include_router(auth_router, prefix=prefix)
app.include_router(bands_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(sessions_router, prefix=prefix)
app.include_router(songs_router, prefix=prefix) app.include_router(songs_router, prefix=prefix)
app.include_router(versions_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) result = await self.session.execute(stmt)
return result.scalar_one_or_none() 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: async def get_by_nc_folder_prefix(self, path: str) -> Band | None:
"""Return the band whose nc_folder_path is a prefix of path.""" """Return the band whose nc_folder_path is a prefix of path."""
stmt = select(Band).where(Band.nc_folder_path.is_not(None)) 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.annotations import router as annotations_router
from rehearsalhub.routers.auth import router as auth_router from rehearsalhub.routers.auth import router as auth_router
from rehearsalhub.routers.bands import router as bands_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.internal import router as internal_router
from rehearsalhub.routers.members import router as members_router from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.sessions import router as sessions_router from rehearsalhub.routers.sessions import router as sessions_router
@@ -11,6 +12,7 @@ from rehearsalhub.routers.ws import router as ws_router
__all__ = [ __all__ = [
"auth_router", "auth_router",
"bands_router", "bands_router",
"invites_router",
"internal_router", "internal_router",
"members_router", "members_router",
"sessions_router", "sessions_router",

View File

@@ -1,12 +1,14 @@
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session 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.dependencies import get_current_member
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate 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.repositories.band import BandRepository
from rehearsalhub.services.band import BandService from rehearsalhub.services.band import BandService
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.nextcloud import NextcloudClient
@@ -14,6 +16,100 @@ from rehearsalhub.storage.nextcloud import NextcloudClient
router = APIRouter(prefix="/bands", tags=["bands"]) 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]) @router.get("", response_model=list[BandRead])
async def list_bands( async def list_bands(
session: AsyncSession = Depends(get_session), 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 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): class BandMemberRead(BaseModel):
model_config = ConfigDict(from_attributes=True) 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