Add invite request system and Gitea Actions CI/CD pipeline
Invite request feature: - Public form to request an invite when INVITE_REQUEST_ENABLED=true - Stores requests in new invite_requests DB table - Emails admins on new request, emails requester on approve/deny - Admin panel tab to review, approve, and deny requests - Approval auto-creates invite code and sends signup link CI/CD pipeline: - Build & push Docker image to Gitea registry on release - Auto-deploy to staging with health check - Manual workflow_dispatch for production deploys Also includes client layout/sizing improvements for card grid and opponent spacing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -149,6 +149,9 @@ class ServerConfig:
|
||||
SECRET_KEY: str = ""
|
||||
INVITE_ONLY: bool = True
|
||||
|
||||
# Allow visitors to request an invite (shown on login page when invite-only)
|
||||
INVITE_REQUEST_ENABLED: bool = False
|
||||
|
||||
# Metered open signups (public beta)
|
||||
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
|
||||
DAILY_OPEN_SIGNUPS: int = 0
|
||||
@@ -203,6 +206,7 @@ class ServerConfig:
|
||||
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
|
||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||
INVITE_REQUEST_ENABLED=get_env_bool("INVITE_REQUEST_ENABLED", False),
|
||||
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
|
||||
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
|
||||
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||
|
||||
@@ -418,3 +418,76 @@ async def revoke_invite_code(
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Invite code not found")
|
||||
return {"message": "Invite code revoked successfully"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Invite Request Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/invite-requests")
|
||||
async def list_invite_requests(
|
||||
status: Optional[str] = None,
|
||||
admin: User = Depends(require_admin_v2),
|
||||
service: AdminService = Depends(get_admin_service_dep),
|
||||
):
|
||||
"""List invite requests, optionally filtered by status (pending, approved, denied)."""
|
||||
requests = await service.get_invite_requests(status=status)
|
||||
return {"requests": [r.to_dict() for r in requests]}
|
||||
|
||||
|
||||
@router.post("/invite-requests/{request_id}/approve")
|
||||
async def approve_invite_request(
|
||||
request_id: int,
|
||||
request: Request,
|
||||
admin: User = Depends(require_admin_v2),
|
||||
service: AdminService = Depends(get_admin_service_dep),
|
||||
):
|
||||
"""Approve an invite request — creates a code and emails the requester."""
|
||||
code = await service.approve_invite_request(
|
||||
request_id=request_id,
|
||||
admin_id=admin.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
if not code:
|
||||
raise HTTPException(status_code=404, detail="Request not found or already handled")
|
||||
|
||||
# Get the request details to send the approval email
|
||||
requests = await service.get_invite_requests()
|
||||
req = next((r for r in requests if r.id == request_id), None)
|
||||
if req:
|
||||
from services.email_service import get_email_service
|
||||
email_service = get_email_service()
|
||||
await email_service.send_invite_approved_email(
|
||||
to=req.email,
|
||||
name=req.name,
|
||||
invite_code=code,
|
||||
)
|
||||
|
||||
return {"code": code, "message": "Request approved and invite sent"}
|
||||
|
||||
|
||||
@router.post("/invite-requests/{request_id}/deny")
|
||||
async def deny_invite_request(
|
||||
request_id: int,
|
||||
request: Request,
|
||||
admin: User = Depends(require_admin_v2),
|
||||
service: AdminService = Depends(get_admin_service_dep),
|
||||
):
|
||||
"""Deny an invite request — optionally emails the requester."""
|
||||
result = await service.deny_invite_request(
|
||||
request_id=request_id,
|
||||
admin_id=admin.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Request not found or already handled")
|
||||
|
||||
from services.email_service import get_email_service
|
||||
email_service = get_email_service()
|
||||
await email_service.send_invite_denied_email(
|
||||
to=result["email"],
|
||||
name=result["name"],
|
||||
)
|
||||
|
||||
return {"message": "Request denied"}
|
||||
|
||||
@@ -75,6 +75,13 @@ class UpdatePreferencesRequest(BaseModel):
|
||||
preferences: dict
|
||||
|
||||
|
||||
class InviteRequestBody(BaseModel):
|
||||
"""Invite request body."""
|
||||
name: str
|
||||
email: str
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class ConvertGuestRequest(BaseModel):
|
||||
"""Convert guest to user request."""
|
||||
guest_id: str
|
||||
@@ -332,6 +339,7 @@ async def signup_info():
|
||||
|
||||
return {
|
||||
"invite_required": invite_required,
|
||||
"invite_request_enabled": config.INVITE_REQUEST_ENABLED,
|
||||
"open_signups_enabled": open_signups_enabled,
|
||||
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
|
||||
"remaining_today": remaining,
|
||||
@@ -339,6 +347,55 @@ async def signup_info():
|
||||
}
|
||||
|
||||
|
||||
@router.post("/request-invite")
|
||||
async def request_invite(
|
||||
request_body: InviteRequestBody,
|
||||
request: Request,
|
||||
):
|
||||
"""
|
||||
Public endpoint: submit a request for an invite code.
|
||||
|
||||
Stores the request in the database and notifies admins via email.
|
||||
"""
|
||||
if not config.INVITE_REQUEST_ENABLED:
|
||||
raise HTTPException(status_code=404, detail="Invite requests are not enabled")
|
||||
|
||||
if not _admin_service:
|
||||
raise HTTPException(status_code=503, detail="Service not initialized")
|
||||
|
||||
name = request_body.name.strip()
|
||||
email = request_body.email.strip().lower()
|
||||
message = request_body.message.strip() if request_body.message else None
|
||||
|
||||
if not name or len(name) > 100:
|
||||
raise HTTPException(status_code=400, detail="Name is required (max 100 characters)")
|
||||
if not email or "@" not in email:
|
||||
raise HTTPException(status_code=400, detail="Valid email is required")
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
request_id = await _admin_service.create_invite_request(
|
||||
name=name,
|
||||
email=email,
|
||||
message=message,
|
||||
ip_address=client_ip,
|
||||
)
|
||||
|
||||
# Notify admin emails
|
||||
if config.ADMIN_EMAILS:
|
||||
from services.email_service import get_email_service
|
||||
email_service = get_email_service()
|
||||
for admin_email in config.ADMIN_EMAILS:
|
||||
await email_service.send_invite_request_admin_notification(
|
||||
to=admin_email,
|
||||
requester_name=name,
|
||||
requester_email=email,
|
||||
message=message or "",
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Your request has been submitted. We'll be in touch!"}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(
|
||||
request_body: VerifyEmailRequest,
|
||||
|
||||
@@ -138,6 +138,35 @@ class InviteCode:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteRequest:
|
||||
"""Invite request details."""
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
message: Optional[str]
|
||||
status: str
|
||||
ip_address: Optional[str]
|
||||
created_at: datetime
|
||||
reviewed_at: Optional[datetime]
|
||||
reviewed_by: Optional[str]
|
||||
reviewed_by_username: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"email": self.email,
|
||||
"message": self.message,
|
||||
"status": self.status,
|
||||
"ip_address": self.ip_address,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
|
||||
"reviewed_by": self.reviewed_by,
|
||||
"reviewed_by_username": self.reviewed_by_username,
|
||||
}
|
||||
|
||||
|
||||
class AdminService:
|
||||
"""
|
||||
Admin operations and moderation service.
|
||||
@@ -1211,6 +1240,183 @@ class AdminService:
|
||||
|
||||
return result != "UPDATE 0"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Invite Requests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def create_invite_request(
|
||||
self,
|
||||
name: str,
|
||||
email: str,
|
||||
message: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Create a new invite request.
|
||||
|
||||
Returns:
|
||||
The request ID.
|
||||
"""
|
||||
async with self.pool.acquire() as conn:
|
||||
# Check for existing pending request from same email
|
||||
existing = await conn.fetchval(
|
||||
"SELECT id FROM invite_requests WHERE email = $1 AND status = 'pending'",
|
||||
email,
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
row_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO invite_requests (name, email, message, ip_address)
|
||||
VALUES ($1, $2, $3, $4::inet)
|
||||
RETURNING id
|
||||
""",
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
ip_address,
|
||||
)
|
||||
|
||||
logger.info(f"New invite request #{row_id} from {email}")
|
||||
return row_id
|
||||
|
||||
async def get_invite_requests(self, status: Optional[str] = None) -> List[InviteRequest]:
|
||||
"""Get invite requests, optionally filtered by status."""
|
||||
async with self.pool.acquire() as conn:
|
||||
query = """
|
||||
SELECT r.id, r.name, r.email, r.message, r.status, r.ip_address,
|
||||
r.created_at, r.reviewed_at, r.reviewed_by,
|
||||
u.username as reviewed_by_username
|
||||
FROM invite_requests r
|
||||
LEFT JOIN users_v2 u ON r.reviewed_by = u.id
|
||||
"""
|
||||
params = []
|
||||
if status:
|
||||
query += " WHERE r.status = $1"
|
||||
params.append(status)
|
||||
query += " ORDER BY r.created_at DESC"
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
return [
|
||||
InviteRequest(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
email=row["email"],
|
||||
message=row["message"],
|
||||
status=row["status"],
|
||||
ip_address=str(row["ip_address"]) if row["ip_address"] else None,
|
||||
created_at=row["created_at"],
|
||||
reviewed_at=row["reviewed_at"],
|
||||
reviewed_by=str(row["reviewed_by"]) if row["reviewed_by"] else None,
|
||||
reviewed_by_username=row["reviewed_by_username"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def approve_invite_request(
|
||||
self,
|
||||
request_id: int,
|
||||
admin_id: str,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Approve an invite request: create an invite code and update the request.
|
||||
|
||||
Returns:
|
||||
The generated invite code, or None if request not found/already handled.
|
||||
"""
|
||||
async with self.pool.acquire() as conn:
|
||||
# Verify request exists and is pending
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
|
||||
request_id,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# Create an invite code for this request
|
||||
code = secrets.token_urlsafe(6).upper()[:8]
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
|
||||
invite_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO invite_codes (code, created_by, expires_at, max_uses)
|
||||
VALUES ($1, $2, $3, 1)
|
||||
RETURNING id
|
||||
""",
|
||||
code,
|
||||
admin_id,
|
||||
expires_at,
|
||||
)
|
||||
|
||||
# Update the request
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE invite_requests
|
||||
SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1, invite_code_id = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
admin_id,
|
||||
invite_id,
|
||||
request_id,
|
||||
)
|
||||
|
||||
await self.audit(
|
||||
admin_id,
|
||||
"approve_invite_request",
|
||||
"invite_request",
|
||||
str(request_id),
|
||||
{"email": row["email"], "invite_code": code},
|
||||
ip_address,
|
||||
)
|
||||
|
||||
logger.info(f"Admin {admin_id} approved invite request #{request_id}, code={code}")
|
||||
return code
|
||||
|
||||
async def deny_invite_request(
|
||||
self,
|
||||
request_id: int,
|
||||
admin_id: str,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Deny an invite request.
|
||||
|
||||
Returns:
|
||||
The request info (name, email) or None if not found/already handled.
|
||||
"""
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
|
||||
request_id,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE invite_requests
|
||||
SET status = 'denied', reviewed_at = NOW(), reviewed_by = $1
|
||||
WHERE id = $2
|
||||
""",
|
||||
admin_id,
|
||||
request_id,
|
||||
)
|
||||
|
||||
await self.audit(
|
||||
admin_id,
|
||||
"deny_invite_request",
|
||||
"invite_request",
|
||||
str(request_id),
|
||||
{"email": row["email"]},
|
||||
ip_address,
|
||||
)
|
||||
|
||||
logger.info(f"Admin {admin_id} denied invite request #{request_id}")
|
||||
return {"name": row["name"], "email": row["email"]}
|
||||
|
||||
|
||||
# Global admin service instance
|
||||
_admin_service: Optional[AdminService] = None
|
||||
|
||||
@@ -165,6 +165,76 @@ class EmailService:
|
||||
|
||||
return await self._send_email(to, subject, html)
|
||||
|
||||
async def send_invite_request_admin_notification(
|
||||
self,
|
||||
to: str,
|
||||
requester_name: str,
|
||||
requester_email: str,
|
||||
message: str,
|
||||
) -> Optional[str]:
|
||||
"""Notify admin of a new invite request."""
|
||||
if not self.is_configured():
|
||||
logger.info(f"Email not configured. Would send invite request notification to {to}")
|
||||
return None
|
||||
|
||||
admin_url = f"{self.base_url}/admin.html"
|
||||
message_html = f"<p><strong>Message:</strong> {message}</p>" if message else ""
|
||||
|
||||
subject = f"Golf Game invite request from {requester_name}"
|
||||
html = f"""
|
||||
<h2>New Invite Request</h2>
|
||||
<p><strong>Name:</strong> {requester_name}</p>
|
||||
<p><strong>Email:</strong> {requester_email}</p>
|
||||
{message_html}
|
||||
<p><a href="{admin_url}">Review in Admin Panel</a></p>
|
||||
"""
|
||||
|
||||
return await self._send_email(to, subject, html)
|
||||
|
||||
async def send_invite_approved_email(
|
||||
self,
|
||||
to: str,
|
||||
name: str,
|
||||
invite_code: str,
|
||||
) -> Optional[str]:
|
||||
"""Notify requester that their invite was approved."""
|
||||
if not self.is_configured():
|
||||
logger.info(f"Email not configured. Would send invite approval to {to}")
|
||||
return None
|
||||
|
||||
signup_url = f"{self.base_url}/?invite={invite_code}"
|
||||
|
||||
subject = "Your Golf Game invite is ready!"
|
||||
html = f"""
|
||||
<h2>You're In, {name}!</h2>
|
||||
<p>Your request to join Golf Game has been approved.</p>
|
||||
<p>Use this link to create your account:</p>
|
||||
<p><a href="{signup_url}">{signup_url}</a></p>
|
||||
<p>Or sign up manually with invite code: <strong>{invite_code}</strong></p>
|
||||
<p>This invite is single-use and expires in 7 days.</p>
|
||||
"""
|
||||
|
||||
return await self._send_email(to, subject, html)
|
||||
|
||||
async def send_invite_denied_email(
|
||||
self,
|
||||
to: str,
|
||||
name: str,
|
||||
) -> Optional[str]:
|
||||
"""Notify requester that their invite was denied."""
|
||||
if not self.is_configured():
|
||||
logger.info(f"Email not configured. Would send invite denial to {to}")
|
||||
return None
|
||||
|
||||
subject = "Golf Game invite request update"
|
||||
html = f"""
|
||||
<h2>Hi {name},</h2>
|
||||
<p>Thanks for your interest in Golf Game. Unfortunately, we're not able to approve your invite request at this time.</p>
|
||||
<p>We may open up registrations in the future — stay tuned!</p>
|
||||
"""
|
||||
|
||||
return await self._send_email(to, subject, html)
|
||||
|
||||
async def _send_email(
|
||||
self,
|
||||
to: str,
|
||||
|
||||
@@ -133,6 +133,20 @@ CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Invite requests table
|
||||
CREATE TABLE IF NOT EXISTS invite_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
message TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
ip_address INET,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by UUID REFERENCES users_v2(id),
|
||||
invite_code_id BIGINT REFERENCES invite_codes(id)
|
||||
);
|
||||
|
||||
-- Player stats table (extended for V2 leaderboards)
|
||||
CREATE TABLE IF NOT EXISTS player_stats (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user