+
+
+
+
+
-
-
-
DISCARD
-
-
-
-
-
-
-
-
-
-
-
+
Holding
+
+
+
+
DISCARD
+
+
+
+
+
+
+
+
+
+
+
+
@@ -427,6 +427,26 @@
+
+
+
+
+
Current Standings
+
+
Scores
+
+
+
+ | Player |
+ Hole |
+ Tot |
+ W |
+
+
+
+
+
+
@@ -911,6 +931,28 @@ TOTAL: 0 + 8 + 16 = 24 points
Already have an account? Login
+
+
+
+
Request an Invite
+
Registration is invite-only. Request access and we'll get back to you.
+
+
Already have an invite? Sign up
+
Already have an account? Login
+
diff --git a/client/style.css b/client/style.css
index 063c7cb..343e2a2 100644
--- a/client/style.css
+++ b/client/style.css
@@ -1024,13 +1024,13 @@ input::placeholder {
/* Card Styles */
.card {
- width: clamp(65px, 5.5vw, 100px);
- height: clamp(91px, 7.7vw, 140px);
+ width: clamp(65px, 7vw, 135px);
+ height: clamp(91px, 9.8vw, 189px);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
- font-size: clamp(2rem, 2.5vw, 3.2rem);
+ font-size: clamp(2rem, 3vw, 3.8rem);
font-weight: bold;
cursor: pointer;
/* No CSS transition - hover effects handled by anime.js */
@@ -1151,7 +1151,7 @@ input::placeholder {
/* Card Grid */
.card-grid {
display: grid;
- grid-template-columns: repeat(3, clamp(65px, 5.5vw, 100px));
+ grid-template-columns: repeat(3, clamp(65px, 7vw, 135px));
gap: clamp(8px, 0.8vw, 14px);
justify-content: center;
}
@@ -1161,18 +1161,22 @@ input::placeholder {
display: flex;
flex-direction: column;
align-items: center;
- gap: 25px;
+ justify-content: space-between;
+ gap: 15px;
width: 100%;
+ flex: 1;
+ min-height: 0;
}
-/* Player row - deck/discard and player cards side by side */
+/* Player row - local player cards */
.player-row {
display: flex;
justify-content: center;
align-items: center;
- gap: 25px;
+ gap: clamp(15px, 2vh, 35px);
width: 100%;
flex-wrap: wrap;
+ padding-bottom: clamp(10px, 2vh, 30px);
}
.opponents-row {
@@ -1180,9 +1184,9 @@ input::placeholder {
flex-wrap: nowrap;
justify-content: center;
align-items: flex-end;
- gap: clamp(12px, 1.8vw, 35px);
- min-height: clamp(120px, 14vw, 200px);
- padding: 8px 20px 0;
+ gap: clamp(12px, 6vw, 120px);
+ min-height: clamp(120px, 18vw, 280px);
+ padding: 15px 20px 0;
width: 100%;
}
@@ -1234,7 +1238,62 @@ input::placeholder {
transform: rotate(8deg);
}
-/* 5 opponents: deeper arch with graduated rotation toward center */
+/* 5 opponents: tighter spacing to fit single row on wide screens */
+.opponents-row:has(.opponent-area:first-child:nth-last-child(5)) {
+ gap: clamp(6px, 2vw, 50px);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5),
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area {
+ flex-shrink: 1;
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5) .card-grid,
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area .card-grid {
+ grid-template-columns: repeat(3, clamp(38px, 4vw, 85px));
+ gap: clamp(2px, 0.4vw, 6px);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5) .card,
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area .card {
+ width: clamp(38px, 4vw, 85px);
+ height: clamp(53px, 5.6vw, 119px);
+ font-size: clamp(0.9rem, 1.3vw, 2.2rem);
+}
+
+/* 5 opponents mid-width: wrap into 2 arch rows (3 + 2) */
+@media (min-width: 750px) and (max-width: 1220px) {
+ .opponents-row:has(.opponent-area:first-child:nth-last-child(5)) {
+ flex-wrap: wrap;
+ gap: clamp(6px, 1.5vw, 20px);
+ row-gap: clamp(4px, 1vw, 16px);
+ }
+ /* Force 3+2 split: each item ~30% so 3 fit per row */
+ .opponents-row .opponent-area:first-child:nth-last-child(5),
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area {
+ flex: 0 0 auto;
+ }
+ /* Row 1 arch: 3 opponents */
+ .opponents-row .opponent-area:first-child:nth-last-child(5) {
+ margin-bottom: 0;
+ transform: rotate(-5deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(2) {
+ margin-bottom: 20px;
+ transform: rotate(0deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(3) {
+ margin-bottom: 0;
+ transform: rotate(5deg);
+ }
+ /* Row 2 arch: 2 opponents */
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(4) {
+ margin-bottom: 8px;
+ transform: rotate(-3deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(5) {
+ margin-bottom: 8px;
+ transform: rotate(3deg);
+ }
+}
+
.opponents-row .opponent-area:first-child:nth-last-child(5) {
margin-bottom: 0;
transform: rotate(-10deg);
@@ -1367,9 +1426,9 @@ input::placeholder {
}
.deck-area .card {
- width: clamp(80px, 7vw, 120px);
- height: clamp(112px, 9.8vw, 168px);
- font-size: clamp(2.4rem, 3.2vw, 4rem);
+ width: clamp(80px, 8.5vw, 150px);
+ height: clamp(112px, 11.9vw, 210px);
+ font-size: clamp(2.4rem, 3.5vw, 4.5rem);
}
#discard {
@@ -1771,14 +1830,14 @@ input::placeholder {
.opponent-area .card-grid {
display: grid;
- grid-template-columns: repeat(3, clamp(45px, 4vw, 75px));
- gap: clamp(4px, 0.4vw, 8px);
+ grid-template-columns: repeat(3, clamp(45px, 5vw, 100px));
+ gap: clamp(4px, 0.5vw, 8px);
}
.opponent-area .card {
- width: clamp(45px, 4vw, 75px);
- height: clamp(63px, 5.6vw, 105px);
- font-size: clamp(1.3rem, 1.5vw, 2.2rem);
+ width: clamp(45px, 5vw, 100px);
+ height: clamp(63px, 7vw, 140px);
+ font-size: clamp(1.3rem, 1.8vw, 2.6rem);
border-radius: 5px;
}
@@ -1929,6 +1988,7 @@ input::placeholder {
align-items: center;
gap: 8px;
width: 100%;
+ min-height: calc(100vh - 50px);
}
/* Side Panels - positioned in bottom corners */
@@ -1953,6 +2013,88 @@ input::placeholder {
right: 15px;
}
+/* Desktop: hide side panels by default, show via scorecard button */
+.side-panel.left-panel,
+.side-panel.right-panel {
+ display: none;
+}
+
+/* Desktop scorecard button - bottom right corner */
+#desktop-scorecard-btn {
+ position: fixed;
+ bottom: 15px;
+ right: 15px;
+ z-index: 99;
+ background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
+ border: 1px solid rgba(244, 164, 96, 0.35);
+ color: #f4a460;
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ padding: 8px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ backdrop-filter: blur(10px);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ transition: all 0.2s ease;
+}
+
+#desktop-scorecard-btn:hover {
+ background: linear-gradient(145deg, rgba(20, 60, 40, 0.95) 0%, rgba(12, 40, 28, 0.97) 100%);
+ border-color: rgba(244, 164, 96, 0.6);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 10px rgba(244, 164, 96, 0.15);
+}
+
+#desktop-scorecard-btn:active {
+ transform: scale(0.96);
+}
+
+#desktop-scorecard-btn.active {
+ background: linear-gradient(135deg, #f4a460, #e8935a);
+ color: #1a472a;
+ border-color: transparent;
+ box-shadow: 0 2px 12px rgba(244, 164, 96, 0.4);
+}
+
+/* Desktop scorecard overlay — combines standings + scores */
+.side-panel.desktop-scorecard-overlay {
+ display: none;
+ position: fixed;
+ bottom: 55px;
+ right: 15px;
+ left: auto;
+ width: 280px;
+ max-height: 70vh;
+ overflow-y: auto;
+ z-index: 100;
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: linear-gradient(145deg, rgba(15, 50, 35, 0.95) 0%, rgba(8, 30, 20, 0.97) 100%);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(244, 164, 96, 0.25);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+
+.side-panel.desktop-scorecard-overlay.open {
+ display: block;
+}
+
+/* Desktop scorecard backdrop */
+.desktop-scorecard-backdrop {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 98;
+}
+
+.desktop-scorecard-backdrop.visible {
+ display: block;
+}
+
.side-panel > h4 {
font-size: 0.7rem;
text-align: center;
@@ -3596,7 +3738,24 @@ input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
-.modal-auth input:focus {
+.modal-auth textarea {
+ width: 100%;
+ padding: 12px 15px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ color: white;
+ font-size: 1rem;
+ font-family: inherit;
+ resize: vertical;
+}
+
+.modal-auth textarea::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.modal-auth input:focus,
+.modal-auth textarea:focus {
outline: none;
border-color: #f4a460;
}
@@ -3612,6 +3771,15 @@ input::placeholder {
color: rgba(255, 255, 255, 0.45);
}
+.form-hint a {
+ color: #f4a460;
+ text-decoration: none;
+}
+
+.form-hint a:hover {
+ text-decoration: underline;
+}
+
.auth-switch {
text-align: center;
margin-top: 15px;
@@ -5336,12 +5504,16 @@ body.mobile-portrait .opponents-row {
flex-shrink: 0;
}
-/* --- Mobile: Player row gets remaining space, centered vertically --- */
+/* --- Mobile: Table center and player row share remaining space --- */
+body.mobile-portrait .table-center {
+ flex-shrink: 0;
+}
+
body.mobile-portrait .player-row {
display: flex;
flex-direction: column;
align-items: center;
- justify-content: space-evenly;
+ justify-content: center;
gap: 10px;
width: 100%;
flex: 1 1 0%;
@@ -5503,6 +5675,18 @@ body.mobile-portrait .real-card .card-face-back {
line-height: 1;
}
+/* Hide desktop scorecard button and backdrop on mobile */
+body.mobile-portrait #desktop-scorecard-btn,
+body.mobile-portrait .desktop-scorecard-backdrop {
+ display: none !important;
+}
+
+/* Re-enable side panels on mobile (overrides desktop hide) */
+body.mobile-portrait .side-panel.left-panel,
+body.mobile-portrait .side-panel.right-panel {
+ display: block;
+}
+
/* --- Mobile: Side panels become bottom drawers --- */
body.mobile-portrait .side-panel {
position: fixed;
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 146cd76..d3060d3 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -40,6 +40,7 @@ services:
- BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true
+ - INVITE_REQUEST_ENABLED=true
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
index 233d81d..f83eacb 100644
--- a/docker-compose.staging.yml
+++ b/docker-compose.staging.yml
@@ -29,6 +29,7 @@ services:
- BASE_URL=${BASE_URL:-https://staging.golfcards.club}
- RATE_LIMIT_ENABLED=false
- INVITE_ONLY=true
+ - INVITE_REQUEST_ENABLED=false
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
diff --git a/server/config.py b/server/config.py
index 66d0d43..152c562 100644
--- a/server/config.py
+++ b/server/config.py
@@ -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", ""),
diff --git a/server/routers/admin.py b/server/routers/admin.py
index 22a2d3f..f0c8a17 100644
--- a/server/routers/admin.py
+++ b/server/routers/admin.py
@@ -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"}
diff --git a/server/routers/auth.py b/server/routers/auth.py
index fa84601..ccae963 100644
--- a/server/routers/auth.py
+++ b/server/routers/auth.py
@@ -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,
diff --git a/server/services/admin_service.py b/server/services/admin_service.py
index 4f8d119..5af3cc4 100644
--- a/server/services/admin_service.py
+++ b/server/services/admin_service.py
@@ -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
diff --git a/server/services/email_service.py b/server/services/email_service.py
index 534dc2f..7c107fe 100644
--- a/server/services/email_service.py
+++ b/server/services/email_service.py
@@ -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"
Message: {message}
" if message else ""
+
+ subject = f"Golf Game invite request from {requester_name}"
+ html = f"""
+
New Invite Request
+
Name: {requester_name}
+
Email: {requester_email}
+ {message_html}
+
Review in Admin Panel
+ """
+
+ 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"""
+
You're In, {name}!
+
Your request to join Golf Game has been approved.
+
Use this link to create your account:
+
{signup_url}
+
Or sign up manually with invite code: {invite_code}
+
This invite is single-use and expires in 7 days.
+ """
+
+ 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"""
+
Hi {name},
+
Thanks for your interest in Golf Game. Unfortunately, we're not able to approve your invite request at this time.
+
We may open up registrations in the future — stay tuned!
+ """
+
+ return await self._send_email(to, subject, html)
+
async def _send_email(
self,
to: str,
diff --git a/server/stores/user_store.py b/server/stores/user_store.py
index 44e4f24..33e996b 100644
--- a/server/stores/user_store.py
+++ b/server/stores/user_store.py
@@ -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,