diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py index 85a3b1b..aa4fded 100644 --- a/backend/app/api/v1/games.py +++ b/backend/app/api/v1/games.py @@ -10,6 +10,7 @@ from app.core.config import settings from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.services.storage import storage_service +from app.services.telegram_notifier import telegram_notifier router = APIRouter(tags=["games"]) @@ -268,6 +269,13 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession): if game.status != GameStatus.PENDING.value: raise HTTPException(status_code=400, detail="Game is not pending") + # Get marathon title for notification + marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) + marathon = marathon_result.scalar_one() + + # Save proposer id before status change + proposer_id = game.proposed_by_id + game.status = GameStatus.APPROVED.value game.approved_by_id = current_user.id @@ -283,6 +291,12 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession): await db.commit() await db.refresh(game) + # Notify proposer (if not self-approving) + if proposer_id and proposer_id != current_user.id: + await telegram_notifier.notify_game_approved( + db, proposer_id, marathon.title, game.title + ) + # Need to reload relationships game = await get_game_or_404(db, game_id) challenges_count = await db.scalar( @@ -302,6 +316,14 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession): if game.status != GameStatus.PENDING.value: raise HTTPException(status_code=400, detail="Game is not pending") + # Get marathon title for notification + marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) + marathon = marathon_result.scalar_one() + + # Save proposer id and game title before changes + proposer_id = game.proposed_by_id + game_title = game.title + game.status = GameStatus.REJECTED.value # Log activity @@ -316,6 +338,12 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession): await db.commit() await db.refresh(game) + # Notify proposer + if proposer_id and proposer_id != current_user.id: + await telegram_notifier.notify_game_rejected( + db, proposer_id, marathon.title, game_title + ) + # Need to reload relationships game = await get_game_or_404(db, game_id) challenges_count = await db.scalar( diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index f71b74e..a04bbcb 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -1,5 +1,6 @@ from datetime import timedelta import secrets +import string from fastapi import APIRouter, HTTPException, status from sqlalchemy import select, func from sqlalchemy.orm import selectinload @@ -10,7 +11,7 @@ from app.api.deps import ( get_participant, ) from app.models import ( - Marathon, Participant, MarathonStatus, Game, GameStatus, + Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, ) from app.schemas import ( @@ -40,7 +41,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession): select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .options(selectinload(Marathon.creator)) - .where(Marathon.invite_code == invite_code) + .where(func.upper(Marathon.invite_code) == invite_code.upper()) .group_by(Marathon.id) ) row = result.first() @@ -62,7 +63,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession): def generate_invite_code() -> str: - return secrets.token_urlsafe(8) + """Generate a clean 8-character uppercase alphanumeric code.""" + alphabet = string.ascii_uppercase + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(8)) async def get_marathon_or_404(db, marathon_id: int) -> Marathon: @@ -272,15 +275,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Marathon is not in preparing state") - # Check if there are approved games with challenges - games_count = await db.scalar( - select(func.count()).select_from(Game).where( + # Check if there are approved games + games_result = await db.execute( + select(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) - if games_count == 0: - raise HTTPException(status_code=400, detail="Add and approve at least one game before starting") + approved_games = games_result.scalars().all() + + if len(approved_games) == 0: + raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру") + + # Check that all approved games have at least one challenge + games_without_challenges = [] + for game in approved_games: + challenge_count = await db.scalar( + select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) + ) + if challenge_count == 0: + games_without_challenges.append(game.title) + + if games_without_challenges: + games_list = ", ".join(games_without_challenges) + raise HTTPException( + status_code=400, + detail=f"У следующих игр нет челленджей: {games_list}" + ) marathon.status = MarathonStatus.ACTIVE.value @@ -332,7 +353,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes @router.post("/join", response_model=MarathonResponse) async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): result = await db.execute( - select(Marathon).where(Marathon.invite_code == data.invite_code) + select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper()) ) marathon = result.scalar_one_or_none() diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py index eda328c..003e544 100644 --- a/backend/app/services/telegram_notifier.py +++ b/backend/app/services/telegram_notifier.py @@ -244,6 +244,38 @@ class TelegramNotifier: ) return await self.notify_user(db, user_id, message) + async def notify_game_approved( + self, + db: AsyncSession, + user_id: int, + marathon_title: str, + game_title: str + ) -> bool: + """Notify user that their proposed game was approved.""" + message = ( + f"✅ Твоя игра одобрена!\n\n" + f"Марафон: {marathon_title}\n" + f"Игра: {game_title}\n\n" + f"Теперь она доступна для всех участников." + ) + return await self.notify_user(db, user_id, message) + + async def notify_game_rejected( + self, + db: AsyncSession, + user_id: int, + marathon_title: str, + game_title: str + ) -> bool: + """Notify user that their proposed game was rejected.""" + message = ( + f"❌ Твоя игра отклонена\n\n" + f"Марафон: {marathon_title}\n" + f"Игра: {game_title}\n\n" + f"Ты можешь предложить другую игру." + ) + return await self.notify_user(db, user_id, message) + # Global instance telegram_notifier = TelegramNotifier() diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index e068a66..52c62e0 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -41,8 +41,9 @@ export const usersApi = { }, // Получить аватар пользователя как blob URL - getAvatarUrl: async (userId: number): Promise => { - const response = await client.get(`/users/${userId}/avatar`, { + getAvatarUrl: async (userId: number, bustCache = false): Promise => { + const cacheBuster = bustCache ? `?t=${Date.now()}` : '' + const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, { responseType: 'blob', }) return URL.createObjectURL(response.data) diff --git a/frontend/src/components/TelegramLink.tsx b/frontend/src/components/TelegramLink.tsx index 4b8ff9b..375048f 100644 --- a/frontend/src/components/TelegramLink.tsx +++ b/frontend/src/components/TelegramLink.tsx @@ -125,8 +125,8 @@ export function TelegramLink() { onClick={() => setIsOpen(true)} className={`p-2 rounded-lg transition-colors ${ isLinked - ? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700' - : 'text-gray-400 hover:text-white hover:bg-gray-700' + ? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700' + : 'text-gray-400 hover:text-white hover:bg-dark-700' }`} title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'} > @@ -134,17 +134,17 @@ export function TelegramLink() { {isOpen && ( -
-
+
+
-
+
@@ -171,7 +171,7 @@ export function TelegramLink() { )} {/* User Profile Card */} -
+
{/* Avatar - Telegram avatar */}
@@ -182,12 +182,12 @@ export function TelegramLink() { className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50" /> ) : ( -
+
)} {/* Link indicator */} -
+
@@ -205,7 +205,7 @@ export function TelegramLink() {
{/* Notifications Info */} -
+

Уведомления включены:

@@ -254,7 +254,7 @@ export function TelegramLink() { -

+

Ссылка действительна 10 минут

@@ -304,7 +304,7 @@ export function TelegramLink() { - + + {variant === 'info' ? ( + + {options.confirmText || 'Подтвердить'} + + ) : ( + + )}
diff --git a/frontend/src/components/ui/GlassCard.tsx b/frontend/src/components/ui/GlassCard.tsx index b2bb7dc..f576a3d 100644 --- a/frontend/src/components/ui/GlassCard.tsx +++ b/frontend/src/components/ui/GlassCard.tsx @@ -91,10 +91,14 @@ export function StatsCard({ className )} > -
+

{label}

-

+

{value}

{trend && ( @@ -111,7 +115,7 @@ export function StatsCard({ {icon && (
diff --git a/frontend/src/components/ui/UserAvatar.tsx b/frontend/src/components/ui/UserAvatar.tsx index f1e89ae..9c9afb0 100644 --- a/frontend/src/components/ui/UserAvatar.tsx +++ b/frontend/src/components/ui/UserAvatar.tsx @@ -3,6 +3,8 @@ import { usersApi } from '@/api' // Глобальный кэш для blob URL аватарок const avatarCache = new Map() +// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе +const needsCacheBust = new Set() interface UserAvatarProps { userId: number @@ -10,6 +12,7 @@ interface UserAvatarProps { nickname: string size?: 'sm' | 'md' | 'lg' className?: string + version?: number // Для принудительного обновления при смене аватара } const sizeClasses = { @@ -18,7 +21,7 @@ const sizeClasses = { lg: 'w-24 h-24 text-xl', } -export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '' }: UserAvatarProps) { +export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) { const [blobUrl, setBlobUrl] = useState(null) const [failed, setFailed] = useState(false) @@ -28,16 +31,31 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className return } - // Проверяем кэш - const cached = avatarCache.get(userId) - if (cached) { - setBlobUrl(cached) - return + // Если version > 0, значит аватар обновился - сбрасываем кэш + const shouldBustCache = version > 0 || needsCacheBust.has(userId) + + // Проверяем кэш только если не нужен bust + if (!shouldBustCache) { + const cached = avatarCache.get(userId) + if (cached) { + setBlobUrl(cached) + return + } + } + + // Очищаем старый кэш если bust + if (shouldBustCache) { + const cached = avatarCache.get(userId) + if (cached) { + URL.revokeObjectURL(cached) + avatarCache.delete(userId) + } + needsCacheBust.delete(userId) } // Загружаем аватарку let cancelled = false - usersApi.getAvatarUrl(userId) + usersApi.getAvatarUrl(userId, shouldBustCache) .then(url => { if (!cancelled) { avatarCache.set(userId, url) @@ -53,7 +71,7 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className return () => { cancelled = true } - }, [userId, hasAvatar]) + }, [userId, hasAvatar, version]) const sizeClass = sizeClasses[size] @@ -84,4 +102,6 @@ export function clearAvatarCache(userId: number) { URL.revokeObjectURL(cached) avatarCache.delete(userId) } + // Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера + needsCacheBust.add(userId) } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index fc8616b..3ea6148 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -33,7 +33,7 @@ type NicknameForm = z.infer type PasswordForm = z.infer export function ProfilePage() { - const { user, updateUser } = useAuthStore() + const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore() const toast = useToast() // State @@ -72,31 +72,57 @@ export function ProfilePage() { } }, []) + // Ref для отслеживания текущего blob URL + const avatarBlobRef = useRef(null) + // Load avatar via API useEffect(() => { - if (user?.id && user?.avatar_url) { - loadAvatar(user.id) - } else { + if (!user?.id || !user?.avatar_url) { setIsLoadingAvatar(false) + return } + + let cancelled = false + const bustCache = avatarVersion > 0 + + setIsLoadingAvatar(true) + usersApi.getAvatarUrl(user.id, bustCache) + .then(url => { + if (cancelled) { + URL.revokeObjectURL(url) + return + } + // Очищаем старый blob URL + if (avatarBlobRef.current) { + URL.revokeObjectURL(avatarBlobRef.current) + } + avatarBlobRef.current = url + setAvatarBlobUrl(url) + }) + .catch(() => { + if (!cancelled) { + setAvatarBlobUrl(null) + } + }) + .finally(() => { + if (!cancelled) { + setIsLoadingAvatar(false) + } + }) + return () => { - if (avatarBlobUrl) { - URL.revokeObjectURL(avatarBlobUrl) + cancelled = true + } + }, [user?.id, user?.avatar_url, avatarVersion]) + + // Cleanup blob URL on unmount + useEffect(() => { + return () => { + if (avatarBlobRef.current) { + URL.revokeObjectURL(avatarBlobRef.current) } } - }, [user?.id, user?.avatar_url]) - - const loadAvatar = async (userId: number) => { - setIsLoadingAvatar(true) - try { - const url = await usersApi.getAvatarUrl(userId) - setAvatarBlobUrl(url) - } catch { - setAvatarBlobUrl(null) - } finally { - setIsLoadingAvatar(false) - } - } + }, []) // Update nickname form when user changes useEffect(() => { @@ -150,12 +176,10 @@ export function ProfilePage() { const updatedUser = await usersApi.uploadAvatar(file) updateUser({ avatar_url: updatedUser.avatar_url }) if (user?.id) { - if (avatarBlobUrl) { - URL.revokeObjectURL(avatarBlobUrl) - } clearAvatarCache(user.id) - await loadAvatar(user.id) } + // Bump version - это вызовет перезагрузку через useEffect + bumpAvatarVersion() toast.success('Аватар обновлен') } catch { toast.error('Не удалось загрузить аватар') diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 5f2fe78..02a3a28 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -10,6 +10,7 @@ interface AuthState { isLoading: boolean error: string | null pendingInviteCode: string | null + avatarVersion: number login: (data: LoginData) => Promise register: (data: RegisterData) => Promise @@ -18,6 +19,7 @@ interface AuthState { setPendingInviteCode: (code: string | null) => void consumePendingInviteCode: () => string | null updateUser: (updates: Partial) => void + bumpAvatarVersion: () => void } export const useAuthStore = create()( @@ -29,6 +31,7 @@ export const useAuthStore = create()( isLoading: false, error: null, pendingInviteCode: null, + avatarVersion: 0, login: async (data) => { set({ isLoading: true, error: null }) @@ -97,6 +100,10 @@ export const useAuthStore = create()( set({ user: { ...currentUser, ...updates } }) } }, + + bumpAvatarVersion: () => { + set({ avatarVersion: get().avatarVersion + 1 }) + }, }), { name: 'auth-storage',