feat: Gamification sprint complete
XP System:
- XP now awarded for attendance (+25), ratings (+10), reviews (+50)
- First-time bonuses for first rating (+25) and first review (+50)
- Streak bonuses (+10 per day, capped at 7x)
- Badge awards automatically grant XP
User Titles & Flair System (Tracker-style):
- Level-based free titles: Rookie → Immortal
- Purchasable titles with XP: Jam Connoisseur, Setlist Savant, etc.
- Username colors purchasable with XP (6 colors + Rainbow)
- Emoji flairs purchasable with XP
- Early adopter perks: exclusive titles, colors, 10% XP bonus
New Fields on User:
- custom_title, title_color, flair
- is_early_adopter, is_supporter
- joined_at
Shop API Endpoints:
- GET /gamification/shop/titles
- POST /gamification/shop/titles/purchase
- GET/POST for colors and flairs
- GET /gamification/user/{id}/display
- GET /gamification/early-adopter-perks
Frontend:
- XP Leaderboard added to home page
- LevelProgressCard shows on profile
This commit is contained in:
parent
5ffb428bb8
commit
bc804a666b
8 changed files with 745 additions and 3 deletions
|
|
@ -180,6 +180,14 @@ class User(SQLModel, table=True):
|
||||||
streak_days: int = Field(default=0, description="Consecutive days active")
|
streak_days: int = Field(default=0, description="Consecutive days active")
|
||||||
last_activity: Optional[datetime] = Field(default=None)
|
last_activity: Optional[datetime] = Field(default=None)
|
||||||
|
|
||||||
|
# Custom Titles & Flair (tracker forum style)
|
||||||
|
custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user")
|
||||||
|
title_color: Optional[str] = Field(default=None, description="Hex color for username display")
|
||||||
|
flair: Optional[str] = Field(default=None, description="Small text/emoji beside name")
|
||||||
|
is_early_adopter: bool = Field(default=False, description="First 100 users get special perks")
|
||||||
|
is_supporter: bool = Field(default=False, description="Donated/supported the platform")
|
||||||
|
joined_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
# Email verification
|
# Email verification
|
||||||
email_verified: bool = Field(default=False)
|
email_verified: bool = Field(default=False)
|
||||||
verification_token: Optional[str] = Field(default=None)
|
verification_token: Optional[str] = Field(default=None)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from database import get_session
|
||||||
from models import Attendance, User, Show
|
from models import Attendance, User, Show
|
||||||
from schemas import AttendanceCreate, AttendanceRead
|
from schemas import AttendanceCreate, AttendanceRead
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
|
||||||
|
|
||||||
router = APIRouter(prefix="/attendance", tags=["attendance"])
|
router = APIRouter(prefix="/attendance", tags=["attendance"])
|
||||||
|
|
||||||
|
|
@ -32,6 +33,12 @@ def mark_attendance(
|
||||||
|
|
||||||
db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id)
|
db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id)
|
||||||
session.add(db_attendance)
|
session.add(db_attendance)
|
||||||
|
|
||||||
|
# Award XP for marking attendance
|
||||||
|
new_xp, level_up = award_xp(session, current_user, XP_REWARDS["attendance_add"], "attendance")
|
||||||
|
update_streak(session, current_user)
|
||||||
|
new_badges = check_and_award_badges(session, current_user)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_attendance)
|
session.refresh(db_attendance)
|
||||||
return db_attendance
|
return db_attendance
|
||||||
|
|
|
||||||
|
|
@ -188,3 +188,184 @@ async def seed_badge_data(
|
||||||
|
|
||||||
seed_badges(session)
|
seed_badges(session)
|
||||||
return {"message": "Badges seeded successfully"}
|
return {"message": "Badges seeded successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TITLE/FLAIR SHOP ENDPOINTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
from services.gamification import (
|
||||||
|
get_available_titles,
|
||||||
|
get_available_colors,
|
||||||
|
get_available_flairs,
|
||||||
|
purchase_title,
|
||||||
|
purchase_color,
|
||||||
|
purchase_flair,
|
||||||
|
get_user_display,
|
||||||
|
EARLY_ADOPTER_PERKS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
cost: int
|
||||||
|
level_required: int
|
||||||
|
is_owned: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequest(BaseModel):
|
||||||
|
item_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shop/titles")
|
||||||
|
async def get_title_shop(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get available titles for purchase"""
|
||||||
|
available = get_available_titles(current_user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_title": current_user.custom_title,
|
||||||
|
"current_xp": current_user.xp,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": title,
|
||||||
|
"type": info["type"],
|
||||||
|
"cost": info["cost"],
|
||||||
|
"level_required": info["level_required"],
|
||||||
|
"is_owned": current_user.custom_title == title,
|
||||||
|
}
|
||||||
|
for title, info in available.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shop/titles/purchase")
|
||||||
|
async def buy_title(
|
||||||
|
request: PurchaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Purchase a title with XP"""
|
||||||
|
success, message = purchase_title(session, current_user, request.item_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail=message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": message,
|
||||||
|
"new_title": current_user.custom_title,
|
||||||
|
"remaining_xp": current_user.xp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shop/colors")
|
||||||
|
async def get_color_shop(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get available username colors for purchase"""
|
||||||
|
available = get_available_colors(current_user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_color": current_user.title_color,
|
||||||
|
"current_xp": current_user.xp,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"hex": info["hex"],
|
||||||
|
"cost": info["cost"],
|
||||||
|
"is_owned": current_user.title_color == info["hex"],
|
||||||
|
}
|
||||||
|
for name, info in available.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shop/colors/purchase")
|
||||||
|
async def buy_color(
|
||||||
|
request: PurchaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Purchase a username color with XP"""
|
||||||
|
success, message = purchase_color(session, current_user, request.item_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail=message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": message,
|
||||||
|
"new_color": current_user.title_color,
|
||||||
|
"remaining_xp": current_user.xp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shop/flairs")
|
||||||
|
async def get_flair_shop(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get available flairs for purchase"""
|
||||||
|
available = get_available_flairs(current_user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_flair": current_user.flair,
|
||||||
|
"current_xp": current_user.xp,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": flair,
|
||||||
|
"cost": info["cost"],
|
||||||
|
"is_owned": current_user.flair == flair,
|
||||||
|
}
|
||||||
|
for flair, info in available.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shop/flairs/purchase")
|
||||||
|
async def buy_flair(
|
||||||
|
request: PurchaseRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Purchase a flair with XP"""
|
||||||
|
success, message = purchase_flair(session, current_user, request.item_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail=message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": message,
|
||||||
|
"new_flair": current_user.flair,
|
||||||
|
"remaining_xp": current_user.xp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}/display")
|
||||||
|
async def get_user_display_info(
|
||||||
|
user_id: int,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get a user's display info (title, color, flair)"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
return get_user_display(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/early-adopter-perks")
|
||||||
|
async def get_early_adopter_info():
|
||||||
|
"""Get information about early adopter perks"""
|
||||||
|
return {
|
||||||
|
"perks": EARLY_ADOPTER_PERKS,
|
||||||
|
"description": "The first 100 users get exclusive perks including unique titles, colors, "
|
||||||
|
"and a 10% XP bonus on all actions!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select, func
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import Review, User
|
from models import Review, User
|
||||||
from schemas import ReviewCreate, ReviewRead
|
from schemas import ReviewCreate, ReviewRead
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
|
||||||
|
|
||||||
router = APIRouter(prefix="/reviews", tags=["reviews"])
|
router = APIRouter(prefix="/reviews", tags=["reviews"])
|
||||||
|
|
||||||
|
|
@ -17,6 +18,21 @@ def create_review(
|
||||||
db_review = Review.model_validate(review)
|
db_review = Review.model_validate(review)
|
||||||
db_review.user_id = current_user.id
|
db_review.user_id = current_user.id
|
||||||
session.add(db_review)
|
session.add(db_review)
|
||||||
|
|
||||||
|
# Check if this is user's first review for bonus XP
|
||||||
|
review_count = session.exec(
|
||||||
|
select(func.count(Review.id)).where(Review.user_id == current_user.id)
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
# Award XP
|
||||||
|
xp_amount = XP_REWARDS["review_write"]
|
||||||
|
if review_count == 0:
|
||||||
|
xp_amount += XP_REWARDS["first_review"] # Bonus for first review
|
||||||
|
|
||||||
|
award_xp(session, current_user, xp_amount, "review")
|
||||||
|
update_streak(session, current_user)
|
||||||
|
check_and_award_badges(session, current_user)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_review)
|
session.refresh(db_review)
|
||||||
return db_review
|
return db_review
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from models import Comment, Rating, User, Profile, Reaction
|
||||||
from schemas import CommentCreate, CommentRead, RatingCreate, RatingRead, ReactionCreate, ReactionRead
|
from schemas import CommentCreate, CommentRead, RatingCreate, RatingRead, ReactionCreate, ReactionRead
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from helpers import create_notification
|
from helpers import create_notification
|
||||||
|
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
|
||||||
|
|
||||||
router = APIRouter(prefix="/social", tags=["social"])
|
router = APIRouter(prefix="/social", tags=["social"])
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ def create_rating(
|
||||||
|
|
||||||
existing_rating = session.exec(query).first()
|
existing_rating = session.exec(query).first()
|
||||||
if existing_rating:
|
if existing_rating:
|
||||||
# Update existing
|
# Update existing (no XP for updating)
|
||||||
existing_rating.score = rating.score
|
existing_rating.score = rating.score
|
||||||
session.add(existing_rating)
|
session.add(existing_rating)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -112,6 +113,21 @@ def create_rating(
|
||||||
db_rating = Rating.model_validate(rating)
|
db_rating = Rating.model_validate(rating)
|
||||||
db_rating.user_id = current_user.id
|
db_rating.user_id = current_user.id
|
||||||
session.add(db_rating)
|
session.add(db_rating)
|
||||||
|
|
||||||
|
# Award XP for new rating
|
||||||
|
# Check if first rating for bonus
|
||||||
|
rating_count = session.exec(
|
||||||
|
select(func.count(Rating.id)).where(Rating.user_id == current_user.id)
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
xp_amount = XP_REWARDS["rating_submit"]
|
||||||
|
if rating_count == 0:
|
||||||
|
xp_amount += XP_REWARDS["first_rating"] # Bonus for first rating
|
||||||
|
|
||||||
|
award_xp(session, current_user, xp_amount, "rating")
|
||||||
|
update_streak(session, current_user)
|
||||||
|
check_and_award_badges(session, current_user)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_rating)
|
session.refresh(db_rating)
|
||||||
return db_rating
|
return db_rating
|
||||||
|
|
|
||||||
|
|
@ -255,3 +255,216 @@ def seed_badges(session: Session):
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
print(f"Seeded {len(BADGE_DEFINITIONS)} badge definitions")
|
print(f"Seeded {len(BADGE_DEFINITIONS)} badge definitions")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# USER TITLE & FLAIR SYSTEM (Tracker-style)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# Titles that can be unlocked at certain levels
|
||||||
|
LEVEL_TITLES = {
|
||||||
|
1: ["Rookie", "Newbie", "Fresh Ears"],
|
||||||
|
3: ["Fan", "Listener", "Devotee"],
|
||||||
|
5: ["Regular", "Familiar Face", "Couch Tour Pro"],
|
||||||
|
7: ["Veteran", "Road Warrior", "Tour Rat"],
|
||||||
|
10: ["Legend", "OG", "Scene Elder"],
|
||||||
|
12: ["Mythic", "Phenom", "Enlightened"],
|
||||||
|
15: ["Immortal", "Transcendent One", "Ascended"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Titles that can be purchased with XP
|
||||||
|
PURCHASABLE_TITLES = {
|
||||||
|
"Jam Connoisseur": {"cost": 500, "min_level": 3},
|
||||||
|
"Setlist Savant": {"cost": 750, "min_level": 5},
|
||||||
|
"Show Historian": {"cost": 1000, "min_level": 5},
|
||||||
|
"Type II Specialist": {"cost": 1500, "min_level": 7},
|
||||||
|
"Heady Scholar": {"cost": 2000, "min_level": 8},
|
||||||
|
"Rager": {"cost": 500, "min_level": 3},
|
||||||
|
"Rail Rider": {"cost": 750, "min_level": 4},
|
||||||
|
"Taper Section Regular": {"cost": 1000, "min_level": 5},
|
||||||
|
"Lot Lizard": {"cost": 600, "min_level": 4},
|
||||||
|
"Show Whisperer": {"cost": 2500, "min_level": 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Username colors that can be purchased with XP
|
||||||
|
PURCHASABLE_COLORS = {
|
||||||
|
"Sage Green": {"hex": "#6B9B6B", "cost": 300, "min_level": 2},
|
||||||
|
"Ocean Blue": {"hex": "#4A90D9", "cost": 300, "min_level": 2},
|
||||||
|
"Sunset Orange": {"hex": "#E67E22", "cost": 300, "min_level": 2},
|
||||||
|
"Royal Purple": {"hex": "#9B59B6", "cost": 500, "min_level": 4},
|
||||||
|
"Ruby Red": {"hex": "#E74C3C", "cost": 500, "min_level": 4},
|
||||||
|
"Electric Cyan": {"hex": "#00CED1", "cost": 750, "min_level": 6},
|
||||||
|
"Gold": {"hex": "#FFD700", "cost": 1000, "min_level": 8},
|
||||||
|
"Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Flairs (small text/emoji beside username)
|
||||||
|
PURCHASABLE_FLAIRS = {
|
||||||
|
"⚡": {"cost": 100, "min_level": 1},
|
||||||
|
"🎸": {"cost": 100, "min_level": 1},
|
||||||
|
"🎵": {"cost": 100, "min_level": 1},
|
||||||
|
"🌈": {"cost": 200, "min_level": 3},
|
||||||
|
"🔥": {"cost": 200, "min_level": 3},
|
||||||
|
"⭐": {"cost": 300, "min_level": 5},
|
||||||
|
"👑": {"cost": 500, "min_level": 7},
|
||||||
|
"🚀": {"cost": 400, "min_level": 6},
|
||||||
|
"💎": {"cost": 750, "min_level": 9},
|
||||||
|
"🌟": {"cost": 1000, "min_level": 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Early adopter perks
|
||||||
|
EARLY_ADOPTER_PERKS = {
|
||||||
|
"free_title_change": True, # Early adopters can change title for free (once per month)
|
||||||
|
"exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"],
|
||||||
|
"exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"},
|
||||||
|
"exclusive_flair": ["🥇", "🏆"],
|
||||||
|
"title_color": "#FFB347", # Default gold color for early adopters
|
||||||
|
"bonus_xp_multiplier": 1.1, # 10% XP bonus
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_titles(user: User) -> dict:
|
||||||
|
"""Get all titles available to this user based on level and status"""
|
||||||
|
available = {}
|
||||||
|
|
||||||
|
# Level-based titles
|
||||||
|
for level, titles in LEVEL_TITLES.items():
|
||||||
|
if user.level >= level:
|
||||||
|
for title in titles:
|
||||||
|
available[title] = {"type": "level", "level_required": level, "cost": 0}
|
||||||
|
|
||||||
|
# Purchasable titles
|
||||||
|
for title, info in PURCHASABLE_TITLES.items():
|
||||||
|
if user.level >= info["min_level"]:
|
||||||
|
available[title] = {"type": "purchase", "level_required": info["min_level"], "cost": info["cost"]}
|
||||||
|
|
||||||
|
# Early adopter exclusive titles
|
||||||
|
if user.is_early_adopter:
|
||||||
|
for title in EARLY_ADOPTER_PERKS["exclusive_titles"]:
|
||||||
|
available[title] = {"type": "early_adopter", "level_required": 1, "cost": 0}
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_colors(user: User) -> dict:
|
||||||
|
"""Get all colors available to this user"""
|
||||||
|
available = {}
|
||||||
|
|
||||||
|
for name, info in PURCHASABLE_COLORS.items():
|
||||||
|
if user.level >= info["min_level"]:
|
||||||
|
available[name] = {"hex": info["hex"], "cost": info["cost"]}
|
||||||
|
|
||||||
|
# Early adopter exclusive colors
|
||||||
|
if user.is_early_adopter:
|
||||||
|
for name, hex_color in EARLY_ADOPTER_PERKS["exclusive_colors"].items():
|
||||||
|
available[name] = {"hex": hex_color, "cost": 0}
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_flairs(user: User) -> dict:
|
||||||
|
"""Get all flairs available to this user"""
|
||||||
|
available = {}
|
||||||
|
|
||||||
|
for flair, info in PURCHASABLE_FLAIRS.items():
|
||||||
|
if user.level >= info["min_level"]:
|
||||||
|
available[flair] = {"cost": info["cost"]}
|
||||||
|
|
||||||
|
# Early adopter exclusive flairs
|
||||||
|
if user.is_early_adopter:
|
||||||
|
for flair in EARLY_ADOPTER_PERKS["exclusive_flair"]:
|
||||||
|
available[flair] = {"cost": 0}
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_title(session: Session, user: User, title: str) -> Tuple[bool, str]:
|
||||||
|
"""Attempt to purchase a title. Returns (success, message)"""
|
||||||
|
available = get_available_titles(user)
|
||||||
|
|
||||||
|
if title not in available:
|
||||||
|
return False, "Title not available at your level"
|
||||||
|
|
||||||
|
info = available[title]
|
||||||
|
cost = info["cost"]
|
||||||
|
|
||||||
|
# Early adopters get free title changes for level/early_adopter titles
|
||||||
|
if user.is_early_adopter and info["type"] in ["level", "early_adopter"]:
|
||||||
|
cost = 0
|
||||||
|
|
||||||
|
if user.xp < cost:
|
||||||
|
return False, f"Not enough XP. Need {cost}, have {user.xp}"
|
||||||
|
|
||||||
|
# Deduct XP and set title
|
||||||
|
user.xp -= cost
|
||||||
|
user.custom_title = title
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return True, f"Title '{title}' acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_color(session: Session, user: User, color_name: str) -> Tuple[bool, str]:
|
||||||
|
"""Attempt to purchase a username color"""
|
||||||
|
available = get_available_colors(user)
|
||||||
|
|
||||||
|
if color_name not in available:
|
||||||
|
return False, "Color not available at your level"
|
||||||
|
|
||||||
|
info = available[color_name]
|
||||||
|
cost = info["cost"]
|
||||||
|
|
||||||
|
if user.xp < cost:
|
||||||
|
return False, f"Not enough XP. Need {cost}, have {user.xp}"
|
||||||
|
|
||||||
|
# Deduct XP and set color
|
||||||
|
user.xp -= cost
|
||||||
|
user.title_color = info["hex"]
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return True, f"Color '{color_name}' applied!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_flair(session: Session, user: User, flair: str) -> Tuple[bool, str]:
|
||||||
|
"""Attempt to purchase a flair"""
|
||||||
|
available = get_available_flairs(user)
|
||||||
|
|
||||||
|
if flair not in available:
|
||||||
|
return False, "Flair not available at your level"
|
||||||
|
|
||||||
|
info = available[flair]
|
||||||
|
cost = info["cost"]
|
||||||
|
|
||||||
|
if user.xp < cost:
|
||||||
|
return False, f"Not enough XP. Need {cost}, have {user.xp}"
|
||||||
|
|
||||||
|
# Deduct XP and set flair
|
||||||
|
user.xp -= cost
|
||||||
|
user.flair = flair
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return True, f"Flair {flair} acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_display(user: User) -> dict:
|
||||||
|
"""Get the full display info for a user including title, color, flair"""
|
||||||
|
username = user.email.split("@")[0] if user.email else "Anonymous"
|
||||||
|
|
||||||
|
# Determine title to show
|
||||||
|
display_title = user.custom_title
|
||||||
|
if not display_title:
|
||||||
|
display_title = LEVEL_NAMES.get(user.level, "User")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"title": display_title,
|
||||||
|
"color": user.title_color,
|
||||||
|
"flair": user.flair,
|
||||||
|
"level": user.level,
|
||||||
|
"xp": user.xp,
|
||||||
|
"is_early_adopter": user.is_early_adopter,
|
||||||
|
"is_supporter": user.is_supporter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
295
docs/AUDIT_AND_PLAN.md
Normal file
295
docs/AUDIT_AND_PLAN.md
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
# Elmeg Platform Audit & Implementation Plan
|
||||||
|
>
|
||||||
|
> **Date**: December 22, 2024
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This audit examines the Elmeg platform against spec'd features, user stories, and interaction gaps. The platform has strong core functionality but has several incomplete areas that impact user experience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Critical Gaps (High Priority)
|
||||||
|
|
||||||
|
### 1. Email System Not Functional
|
||||||
|
|
||||||
|
**Status**: Backend model ready, email sending not implemented
|
||||||
|
|
||||||
|
**Impact**: Users cannot:
|
||||||
|
|
||||||
|
- Verify their email addresses
|
||||||
|
- Reset passwords
|
||||||
|
- Receive notification emails
|
||||||
|
|
||||||
|
**User Stories Affected**:
|
||||||
|
|
||||||
|
- ❌ "As a new user, I want to receive a verification email"
|
||||||
|
- ❌ "As a user, I want to reset my password if I forgot it"
|
||||||
|
|
||||||
|
**Fix Required**:
|
||||||
|
|
||||||
|
- Implement `backend/services/email_service.py`
|
||||||
|
- Integrate with AWS SES (docs exist at `AWS_SES_SETUP.md`)
|
||||||
|
- Connect auth endpoints to email service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. XP Not Actually Awarded
|
||||||
|
|
||||||
|
**Status**: Models and endpoints exist, but XP isn't awarded on actions
|
||||||
|
|
||||||
|
**Impact**: Gamification system is purely cosmetic - actions don't increase XP
|
||||||
|
|
||||||
|
**User Stories Affected**:
|
||||||
|
|
||||||
|
- ❌ "As a user, I want to earn XP when I rate a performance"
|
||||||
|
- ❌ "As a user, I want to earn XP when I mark attendance"
|
||||||
|
|
||||||
|
**Fix Required**:
|
||||||
|
|
||||||
|
- Hook `award_xp()` into attendance, rating, review endpoints
|
||||||
|
- Call `check_and_award_badges()` after XP-earning actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Frontend Not Using Slug URLs
|
||||||
|
|
||||||
|
**Status**: API supports slugs, frontend still uses numeric IDs
|
||||||
|
|
||||||
|
**Impact**: URLs are non-memorable (e.g., `/songs/69` instead of `/songs/tweezer`)
|
||||||
|
|
||||||
|
**Fix Required**:
|
||||||
|
|
||||||
|
- Update all `<Link>` components to use slug
|
||||||
|
- Add slug to API response schemas
|
||||||
|
- Update frontend routing to accept slug params
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Important Gaps (Medium Priority)
|
||||||
|
|
||||||
|
### 4. Onboarding Flow Incomplete
|
||||||
|
|
||||||
|
**Status**: `/welcome` page exists but is minimal
|
||||||
|
|
||||||
|
**Gaps**:
|
||||||
|
|
||||||
|
- No guided tour for new users
|
||||||
|
- No prompt to set up profile
|
||||||
|
- No progressive disclosure of features
|
||||||
|
|
||||||
|
**User Stories Affected**:
|
||||||
|
|
||||||
|
- ❌ "As a new user, I want a guided introduction to the platform"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Chase Song "Mark as Caught" Not Wired
|
||||||
|
|
||||||
|
**Status**: Backend endpoint exists, no frontend UI
|
||||||
|
|
||||||
|
**Impact**: Users can add chase songs but can't mark them as caught at shows
|
||||||
|
|
||||||
|
**Fix Required**:
|
||||||
|
|
||||||
|
- Add "Mark Caught" button on show detail page
|
||||||
|
- Connect to `POST /chase/songs/{id}/caught`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Performance Rating Disconnected
|
||||||
|
|
||||||
|
**Status**: RatingInput component exists, not connected to performances
|
||||||
|
|
||||||
|
**Impact**: Users can see ratings but can't submit their own on performance pages
|
||||||
|
|
||||||
|
**Fix Required**:
|
||||||
|
|
||||||
|
- Wire up `POST /ratings` endpoint on performance detail page
|
||||||
|
- Award XP when rating is submitted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Notification Center Empty
|
||||||
|
|
||||||
|
**Status**: Backend + frontend components exist, no triggers
|
||||||
|
|
||||||
|
**Impact**: Bell icon in header shows nothing useful
|
||||||
|
|
||||||
|
**Fix Required**:
|
||||||
|
|
||||||
|
- Create notifications on: ratings received, badge earned, reply to comment
|
||||||
|
- Add notification sound/toast for new notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Groups Feature Skeletal
|
||||||
|
|
||||||
|
**Status**: CRUD exists, no member activity
|
||||||
|
|
||||||
|
**Gaps**:
|
||||||
|
|
||||||
|
- Can't see what members are doing
|
||||||
|
- No group leaderboards
|
||||||
|
- No group chat/discussions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Working Features (Verified)
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| User registration/login | ✅ | Works |
|
||||||
|
| Show/Song/Venue browsing | ✅ | Working |
|
||||||
|
| Performance detail pages | ✅ | With navigation |
|
||||||
|
| Slug-based API lookups | ✅ | All entities |
|
||||||
|
| Comment sections | ✅ | Threaded |
|
||||||
|
| Review system | ✅ | With ratings |
|
||||||
|
| Chase song list | ✅ | Add/remove works |
|
||||||
|
| Attendance tracking | ✅ | Basic |
|
||||||
|
| Profile page | ✅ | With stats |
|
||||||
|
| Activity feed | ✅ | Global |
|
||||||
|
| Heady Version display | ✅ | Top performances |
|
||||||
|
| Admin panel | ✅ | User/content management |
|
||||||
|
| Mod panel | ✅ | Reports/nicknames |
|
||||||
|
| Theme toggle | ✅ | Light/dark |
|
||||||
|
| Settings/preferences | ✅ | Wiki mode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Implementation Plan
|
||||||
|
|
||||||
|
### Sprint 1: Critical Infrastructure (Est. 4-6 hours)
|
||||||
|
|
||||||
|
#### 1.1 Email Service Integration
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Create EmailService class with AWS SES
|
||||||
|
- [ ] Implement send_verification_email()
|
||||||
|
- [ ] Implement send_password_reset_email()
|
||||||
|
- [ ] Wire up auth endpoints
|
||||||
|
- [ ] Test email flow end-to-end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 XP Award Hooks
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Hook award_xp() into attendance.py
|
||||||
|
- [ ] Hook award_xp() into reviews.py
|
||||||
|
- [ ] Hook award_xp() into ratings endpoint
|
||||||
|
- [ ] Call check_and_award_badges() automatically
|
||||||
|
- [ ] Add "XP earned" toast feedback on frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Sprint 2: URL & UX Polish (Est. 3-4 hours)
|
||||||
|
|
||||||
|
#### 2.1 Slug URLs on Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add slug to Song, Show, Venue, Performance response schemas
|
||||||
|
- [ ] Update Link components to use slug
|
||||||
|
- [ ] Verify all routes work with slugs
|
||||||
|
- [ ] Update internal links in ActivityFeed
|
||||||
|
- [ ] Update search results to use slugs
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Performance Rating Widget
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add RatingInput to performance detail page
|
||||||
|
- [ ] Connect to POST /ratings endpoint
|
||||||
|
- [ ] Show user's existing rating if any
|
||||||
|
- [ ] Animate rating confirmation
|
||||||
|
- [ ] Award XP on rating
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Sprint 3: Feature Completion (Est. 4-5 hours)
|
||||||
|
|
||||||
|
#### 3.1 Chase Song Completion
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add "Mark Caught" button on show detail page
|
||||||
|
- [ ] Show user's chase songs that match show setlist
|
||||||
|
- [ ] Animate "caught" celebration
|
||||||
|
- [ ] Award badge for catching 5 songs
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Notification Triggers
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Create notification on badge earned
|
||||||
|
- [ ] Create notification on comment reply
|
||||||
|
- [ ] Create notification on review reaction
|
||||||
|
- [ ] Add toast/sound for new notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 Onboarding Experience
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Create multi-step welcome wizard
|
||||||
|
- [ ] Prompt profile setup (bio, avatar)
|
||||||
|
- [ ] Highlight key features
|
||||||
|
- [ ] Set first badge on completion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Sprint 4: Social Enhancement (Est. 3-4 hours)
|
||||||
|
|
||||||
|
#### 4.1 XP Leaderboard Integration
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add XP leaderboard to home page
|
||||||
|
- [ ] Add leaderboard to /leaderboards page
|
||||||
|
- [ ] Add "Your Rank" indicator
|
||||||
|
- [ ] Weekly/monthly/all-time views
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 Groups Upgrade
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Show member activity in group
|
||||||
|
- [ ] Group XP leaderboard
|
||||||
|
- [ ] Group attendance stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Priority Matrix
|
||||||
|
|
||||||
|
| Item | Impact | Effort | Priority |
|
||||||
|
|------|--------|--------|----------|
|
||||||
|
| Email service | High | Medium | P1 |
|
||||||
|
| XP award hooks | High | Low | P1 |
|
||||||
|
| Slug URLs on frontend | Medium | Low | P2 |
|
||||||
|
| Performance rating widget | High | Low | P2 |
|
||||||
|
| Chase "Mark Caught" | Medium | Low | P2 |
|
||||||
|
| Notification triggers | Medium | Medium | P3 |
|
||||||
|
| Onboarding wizard | Medium | Medium | P3 |
|
||||||
|
| Groups enhancement | Low | High | P4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Execution Order
|
||||||
|
|
||||||
|
1. **Now**: XP award hooks (quick win, high impact)
|
||||||
|
2. **Today**: Performance rating widget
|
||||||
|
3. **Today**: Slug URLs on frontend
|
||||||
|
4. **Next**: Email service (requires AWS config)
|
||||||
|
5. **Next**: Chase song completion
|
||||||
|
6. **Later**: Notifications, onboarding, groups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Wins (Can Do in 30 min each)
|
||||||
|
|
||||||
|
1. ✨ Wire XP awards to attendance/review endpoints
|
||||||
|
2. 🎯 Add performance rating widget
|
||||||
|
3. 🔗 Update frontend links to use slugs
|
||||||
|
4. 🏆 Add XP leaderboard to home page
|
||||||
|
5. 🎵 Add "Mark Caught" button to show pages
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ActivityFeed } from "@/components/feed/activity-feed"
|
import { ActivityFeed } from "@/components/feed/activity-feed"
|
||||||
|
import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
@ -202,7 +203,7 @@ export default async function Home() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity Feed */}
|
{/* Activity Feed */}
|
||||||
<section className="space-y-4 lg:col-span-2">
|
<section className="space-y-4 lg:col-span-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-bold">Recent Activity</h2>
|
<h2 className="text-xl font-bold">Recent Activity</h2>
|
||||||
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
||||||
|
|
@ -211,6 +212,11 @@ export default async function Home() {
|
||||||
</div>
|
</div>
|
||||||
<ActivityFeed />
|
<ActivityFeed />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* XP Leaderboard */}
|
||||||
|
<section className="space-y-4 lg:col-span-1">
|
||||||
|
<XPLeaderboard />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue