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

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