Initial commit: RehearsalHub POC
Full-stack self-hosted band rehearsal platform: Backend (FastAPI + SQLAlchemy 2.0 async): - Auth with JWT (register, login, /me, settings) - Band management with Nextcloud folder integration - Song management with audio version tracking - Nextcloud scan to auto-import audio files - Band membership with link-based invite system - Song comments - Audio analysis worker (BPM, key, loudness, waveform) - Nextcloud activity watcher for auto-import - WebSocket support for real-time annotation updates - Alembic migrations (0001–0003) - Repository pattern, Ruff + mypy configured Frontend (React 18 + Vite + TypeScript strict): - Login/register page with post-login redirect - Home page with band list and creation form - Band page with member panel, invite link, song list, NC scan - Song page with waveform player, annotations, comment thread - Settings page for per-user Nextcloud credentials - Invite acceptance page (/invite/:token) - ESLint v9 flat config + TypeScript strict mode Infrastructure: - Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx - nginx reverse proxy for static files + /api/ proxy - make check runs all linters before docker compose build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
134
api/src/rehearsalhub/routers/members.py
Normal file
134
api/src/rehearsalhub/routers/members.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Band membership and invite endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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.dependencies import get_current_member
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.schemas.invite import BandInviteRead, BandMemberRead
|
||||
from rehearsalhub.services.band import BandService
|
||||
|
||||
router = APIRouter(tags=["members"])
|
||||
|
||||
|
||||
@router.get("/bands/{band_id}/members", response_model=list[BandMemberRead])
|
||||
async def list_members(
|
||||
band_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
svc = BandService(session)
|
||||
try:
|
||||
await svc.assert_membership(band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
|
||||
band = await svc.get_band_with_members(band_id)
|
||||
if band is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
|
||||
|
||||
return [
|
||||
BandMemberRead(
|
||||
id=bm.member.id,
|
||||
display_name=bm.member.display_name,
|
||||
email=bm.member.email,
|
||||
role=bm.role,
|
||||
joined_at=bm.joined_at,
|
||||
)
|
||||
for bm in band.memberships
|
||||
]
|
||||
|
||||
|
||||
@router.post("/bands/{band_id}/invites", response_model=BandInviteRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_invite(
|
||||
band_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
svc = BandService(session)
|
||||
try:
|
||||
await svc.assert_admin(band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||
|
||||
repo = BandRepository(session)
|
||||
invite = await repo.create_invite(band_id, current_member.id)
|
||||
return BandInviteRead.model_validate(invite)
|
||||
|
||||
|
||||
@router.delete("/bands/{band_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
band_id: uuid.UUID,
|
||||
member_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
svc = BandService(session)
|
||||
try:
|
||||
await svc.assert_admin(band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||
|
||||
if member_id == current_member.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove yourself")
|
||||
|
||||
repo = BandRepository(session)
|
||||
await repo.remove_member(band_id, member_id)
|
||||
|
||||
|
||||
@router.post("/invites/{token}/accept", response_model=BandMemberRead)
|
||||
async def accept_invite(
|
||||
token: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
repo = BandRepository(session)
|
||||
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")
|
||||
if invite.used_at is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite already used")
|
||||
if invite.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Invite expired")
|
||||
|
||||
# Idempotent — already a member
|
||||
existing_role = await repo.get_member_role(invite.band_id, current_member.id)
|
||||
if existing_role is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member")
|
||||
|
||||
bm = await repo.add_member(invite.band_id, current_member.id, role=invite.role)
|
||||
|
||||
# Mark invite as used
|
||||
invite.used_at = datetime.now(timezone.utc)
|
||||
invite.used_by = current_member.id
|
||||
await session.flush()
|
||||
|
||||
return BandMemberRead(
|
||||
id=current_member.id,
|
||||
display_name=current_member.display_name,
|
||||
email=current_member.email,
|
||||
role=bm.role,
|
||||
joined_at=bm.joined_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invites/{token}", response_model=BandInviteRead)
|
||||
async def get_invite(token: str, session: AsyncSession = Depends(get_session)):
|
||||
"""Preview invite info (band name etc.) before accepting — no auth required."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import select
|
||||
from rehearsalhub.db.models import BandInvite
|
||||
stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token)
|
||||
result = await session.execute(stmt)
|
||||
invite = result.scalar_one_or_none()
|
||||
if invite is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
return BandInviteRead.model_validate(invite)
|
||||
Reference in New Issue
Block a user