Compare commits

..

14 Commits

Author SHA1 Message Date
ca41c207b3 Add info if linked acc 2025-12-16 20:59:50 +07:00
412de3bf05 Add telegram bot 2025-12-16 20:06:16 +07:00
9fd93a185c Improved prompt for GPT 2025-12-16 03:53:53 +07:00
fe6012b7a3 Add manual add for challanges 2025-12-16 03:27:57 +07:00
a199952383 Change points balance 2025-12-16 03:06:26 +07:00
e32df4d95e Fix dispute 2025-12-16 02:35:59 +07:00
f57a2ba9ea Add marathon finish button and system 2025-12-16 02:22:12 +07:00
d96f8de568 Add limits for content + fix video playback 2025-12-16 02:01:03 +07:00
574140e67d Add modals 2025-12-16 01:50:40 +07:00
87ecd9756c Moved to S3 2025-12-16 01:33:29 +07:00
c7966656d8 Add dispute system 2025-12-16 00:33:50 +07:00
339a212e57 Change rematch event to change game 2025-12-15 23:50:37 +07:00
07e02ce32d Common enemy rework 2025-12-15 23:03:59 +07:00
9a037cb34f Add events history 2025-12-15 22:31:42 +07:00
77 changed files with 6941 additions and 662 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
*/node_modules
# Build outputs
dist
build
*.pyc
__pycache__
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# OS files
.DS_Store
Thumbs.db
# Test & coverage
coverage
.pytest_cache
.coverage
# Misc
*.md
!README.md

View File

@@ -11,5 +11,14 @@ OPENAI_API_KEY=sk-...
# Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
S3_ENABLED=false
S3_BUCKET_NAME=your-bucket-name
S3_REGION=ru-1
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_ENDPOINT_URL=https://s3.firstvds.ru
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
# Frontend (for build)
VITE_API_URL=/api/v1

View File

@@ -0,0 +1,54 @@
"""Add is_event_assignment and event_id to assignments for Common Enemy support
Revision ID: 007_add_event_assignment_fields
Revises: 006_add_swap_requests
Create Date: 2024-12-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007_add_event_assignment_fields'
down_revision: Union[str, None] = '006_add_swap_requests'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add is_event_assignment column with default False
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('assignments')]
if 'is_event_assignment' not in columns:
op.add_column(
'assignments',
sa.Column('is_event_assignment', sa.Boolean(), nullable=False, server_default=sa.false())
)
op.create_index('ix_assignments_is_event_assignment', 'assignments', ['is_event_assignment'])
if 'event_id' not in columns:
op.add_column(
'assignments',
sa.Column('event_id', sa.Integer(), nullable=True)
)
op.create_foreign_key(
'fk_assignments_event_id',
'assignments',
'events',
['event_id'],
['id'],
ondelete='SET NULL'
)
op.create_index('ix_assignments_event_id', 'assignments', ['event_id'])
def downgrade() -> None:
op.drop_index('ix_assignments_event_id', table_name='assignments')
op.drop_constraint('fk_assignments_event_id', 'assignments', type_='foreignkey')
op.drop_column('assignments', 'event_id')
op.drop_index('ix_assignments_is_event_assignment', table_name='assignments')
op.drop_column('assignments', 'is_event_assignment')

View File

@@ -0,0 +1,41 @@
"""Rename rematch event type to game_choice
Revision ID: 008_rename_to_game_choice
Revises: 007_add_event_assignment_fields
Create Date: 2024-12-15
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "008_rename_to_game_choice"
down_revision = "007_add_event_assignment_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Update event type from 'rematch' to 'game_choice' in events table
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
# Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
WHERE data->>'event_type' = 'rematch'
""")
def downgrade() -> None:
# Revert event type from 'game_choice' to 'rematch'
op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'")
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"')
WHERE data->>'event_type' = 'game_choice'
""")

View File

@@ -0,0 +1,81 @@
"""Add disputes tables for proof verification system
Revision ID: 009_add_disputes
Revises: 008_rename_to_game_choice
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009_add_disputes'
down_revision: Union[str, None] = '008_rename_to_game_choice'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# Create disputes table
if 'disputes' not in tables:
op.create_table(
'disputes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('raised_by_id', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='open'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['raised_by_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('assignment_id', name='uq_dispute_assignment'),
)
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'])
# Create dispute_comments table
if 'dispute_comments' not in tables:
op.create_table(
'dispute_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_dispute_comments_dispute_id', 'dispute_comments', ['dispute_id'])
# Create dispute_votes table
if 'dispute_votes' not in tables:
op.create_table(
'dispute_votes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('vote', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('dispute_id', 'user_id', name='uq_dispute_vote_user'),
)
op.create_index('ix_dispute_votes_dispute_id', 'dispute_votes', ['dispute_id'])
def downgrade() -> None:
op.drop_index('ix_dispute_votes_dispute_id', table_name='dispute_votes')
op.drop_table('dispute_votes')
op.drop_index('ix_dispute_comments_dispute_id', table_name='dispute_comments')
op.drop_table('dispute_comments')
op.drop_index('ix_disputes_assignment_id', table_name='disputes')
op.drop_table('disputes')

View File

@@ -0,0 +1,30 @@
"""Add telegram profile fields to users
Revision ID: 010_add_telegram_profile
Revises: 009_add_disputes
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '010_add_telegram_profile'
down_revision: Union[str, None] = '009_add_disputes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'telegram_avatar_url')
op.drop_column('users', 'telegram_last_name')
op.drop_column('users', 'telegram_first_name')

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
router = APIRouter(prefix="/api/v1")
@@ -13,3 +13,5 @@ router.include_router(wheel.router)
router.include_router(feed.router)
router.include_router(admin.router)
router.include_router(events.router)
router.include_router(assignments.router)
router.include_router(telegram.router)

View File

@@ -0,0 +1,555 @@
"""
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
)
from app.schemas.user import UserPublic
from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["assignments"])
# Dispute window: 24 hours after completion
DISPUTE_WINDOW_HOURS = 24
def user_to_public(user: User) -> UserPublic:
"""Convert User model to UserPublic schema"""
return UserPublic(
id=user.id,
login=user.login,
nickname=user.nickname,
avatar_url=None,
role=user.role,
created_at=user.created_at,
)
def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse:
"""Build DisputeResponse from Dispute model"""
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
my_vote = None
for v in dispute.votes:
if v.user_id == current_user_id:
my_vote = v.vote
break
# Ensure expires_at has UTC timezone info for correct frontend parsing
created_at_utc = dispute.created_at.replace(tzinfo=timezone.utc) if dispute.created_at.tzinfo is None else dispute.created_at
expires_at = created_at_utc + timedelta(hours=DISPUTE_WINDOW_HOURS)
return DisputeResponse(
id=dispute.id,
raised_by=user_to_public(dispute.raised_by),
reason=dispute.reason,
status=dispute.status,
comments=[
DisputeCommentResponse(
id=c.id,
user=user_to_public(c.user),
text=c.text,
created_at=c.created_at,
)
for c in sorted(dispute.comments, key=lambda x: x.created_at)
],
votes=[
{
"user": user_to_public(v.user),
"vote": v.vote,
"created_at": v.created_at,
}
for v in dispute.votes
],
votes_valid=votes_valid,
votes_invalid=votes_invalid,
my_vote=my_vote,
expires_at=expires_at,
created_at=dispute.created_at,
resolved_at=dispute.resolved_at,
)
@router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse)
async def get_assignment_detail(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get detailed information about an assignment including proofs and dispute"""
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Build response
challenge = assignment.challenge
game = challenge.game
owner_user = assignment.participant.user
# Determine if user can dispute
can_dispute = False
if (
assignment.status == AssignmentStatus.COMPLETED.value
and assignment.completed_at
and assignment.participant.user_id != current_user.id
and assignment.dispute is None
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build proof URLs
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
return AssignmentDetailResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
participant=user_to_public(owner_user),
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
can_dispute=can_dispute,
dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None,
)
@router.get("/assignments/{assignment_id}/proof-media")
async def get_assignment_proof_media(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof media (image or video) for an assignment with Range support"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if proof exists
if not assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof media for this assignment")
# Get file from storage
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof media not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
# Parse range header
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
# Ensure valid range
if start >= file_size:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, file_size - 1)
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
# No range header - return full video with Accept-Ranges
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
# Keep old endpoint for backwards compatibility
@router.get("/assignments/{assignment_id}/proof-image")
async def get_assignment_proof_image(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
return await get_assignment_proof_media(assignment_id, request, current_user, db)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,
data: DisputeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against an assignment's proof"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant),
selectinload(Assignment.dispute),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Validate
if assignment.status != AssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="Can only dispute completed assignments")
if assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
if assignment.dispute:
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
if not assignment.completed_at:
raise HTTPException(status_code=400, detail="Assignment has no completion date")
time_since_completion = datetime.utcnow() - assignment.completed_at
if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS):
raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)")
# Create dispute
dispute = Dispute(
assignment_id=assignment_id,
raised_by_id=current_user.id,
reason=data.reason,
status=DisputeStatus.OPEN.value,
)
db.add(dispute)
await db.commit()
await db.refresh(dispute)
# Send notification to assignment owner
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title
)
# Load relationships for response
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Dispute.id == dispute.id)
)
dispute = result.scalar_one()
return build_dispute_response(dispute, current_user.id)
@router.post("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse)
async def add_dispute_comment(
dispute_id: int,
data: DisputeCommentCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Add a comment to a dispute discussion"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Create comment
comment = DisputeComment(
dispute_id=dispute_id,
user_id=current_user.id,
text=data.text,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
# Get user for response
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one()
return DisputeCommentResponse(
id=comment.id,
user=user_to_public(user),
text=comment.text,
created_at=comment.created_at,
)
@router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse)
async def vote_on_dispute(
dispute_id: int,
data: DisputeVoteCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if user already voted
result = await db.execute(
select(DisputeVote).where(
DisputeVote.dispute_id == dispute_id,
DisputeVote.user_id == current_user.id,
)
)
existing_vote = result.scalar_one_or_none()
if existing_vote:
# Update existing vote
existing_vote.vote = data.vote
existing_vote.created_at = datetime.utcnow()
else:
# Create new vote
vote = DisputeVote(
dispute_id=dispute_id,
user_id=current_user.id,
vote=data.vote,
)
db.add(vote)
await db.commit()
vote_text = "валидным" if data.vote else "невалидным"
return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}")
@router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse])
async def get_returned_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's returned assignments that need to be redone"""
# Check user is participant
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get returned assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.dispute),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.RETURNED.value,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
)
assignments = result.scalars().all()
return [
ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
)
for a in assignments
]

View File

@@ -10,15 +10,19 @@ from app.models import (
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
)
from fastapi import UploadFile, File, Form
from app.schemas import (
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
CommonEnemyLeaderboard,
CommonEnemyLeaderboard, EventAssignmentResponse, AssignmentResponse, CompleteResult,
)
from app.core.config import settings
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
from app.schemas.user import UserPublic
from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["events"])
@@ -635,129 +639,173 @@ async def cancel_swap_request(
return MessageResponse(message="Swap request cancelled")
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse)
async def rematch_assignment(
# ==================== Game Choice Event Endpoints ====================
class GameChoiceChallengeResponse(BaseModel):
"""Challenge option for game choice event"""
id: int
title: str
description: str
difficulty: str
points: int
estimated_time: int | None
proof_type: str
proof_hint: str | None
class GameChoiceChallengesResponse(BaseModel):
"""Response with available challenges for game choice"""
game_id: int
game_title: str
challenges: list[GameChoiceChallengeResponse]
class GameChoiceSelectRequest(BaseModel):
"""Request to select a challenge during game choice event"""
challenge_id: int
@router.get("/marathons/{marathon_id}/game-choice/challenges", response_model=GameChoiceChallengesResponse)
async def get_game_choice_challenges(
marathon_id: int,
assignment_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Retry a dropped assignment (during rematch event)"""
"""Get 3 random challenges from a game for game choice event"""
from app.models import Game
from sqlalchemy.sql.expression import func
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active rematch event
# Check active game_choice event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.REMATCH.value:
raise HTTPException(status_code=400, detail="No active rematch event")
if not event or event.type != EventType.GAME_CHOICE.value:
raise HTTPException(status_code=400, detail="No active game choice event")
# Check no current active assignment
# Get the game
result = await db.execute(
select(Assignment).where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
select(Game).where(Game.id == game_id, Game.marathon_id == marathon_id)
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You already have an active assignment")
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Get the dropped assignment
# Get 3 random challenges from this game
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(func.random())
.limit(3)
)
challenges = result.scalars().all()
if not challenges:
raise HTTPException(status_code=400, detail="No challenges available for this game")
return GameChoiceChallengesResponse(
game_id=game.id,
game_title=game.title,
challenges=[
GameChoiceChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
)
for c in challenges
],
)
@router.post("/marathons/{marathon_id}/game-choice/select", response_model=MessageResponse)
async def select_game_choice_challenge(
marathon_id: int,
data: GameChoiceSelectRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Select a challenge during game choice event (replaces current assignment if any)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active game_choice event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.GAME_CHOICE.value:
raise HTTPException(status_code=400, detail="No active game choice event")
# Get the challenge
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == data.challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Verify challenge belongs to this marathon
if challenge.game.marathon_id != marathon_id:
raise HTTPException(status_code=400, detail="Challenge does not belong to this marathon")
# Check for current active assignment (non-event)
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.challenge))
.where(
Assignment.id == assignment_id,
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == False,
)
)
dropped = result.scalar_one_or_none()
if not dropped:
raise HTTPException(status_code=404, detail="Dropped assignment not found")
current_assignment = result.scalar_one_or_none()
# Create new assignment for the same challenge (with rematch event_type for 50% points)
# If there's a current assignment, replace it (free drop during this event)
old_challenge_title = None
if current_assignment:
old_challenge_title = current_assignment.challenge.title
# Mark old assignment as dropped (no penalty during game_choice event)
current_assignment.status = AssignmentStatus.DROPPED.value
current_assignment.completed_at = datetime.utcnow()
# Create new assignment with chosen challenge
new_assignment = Assignment(
participant_id=participant.id,
challenge_id=dropped.challenge_id,
challenge_id=data.challenge_id,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.REMATCH.value,
event_type=EventType.GAME_CHOICE.value,
)
db.add(new_assignment)
# Log activity
activity_data = {
"game": challenge.game.title,
"challenge": challenge.title,
"event_type": EventType.GAME_CHOICE.value,
}
if old_challenge_title:
activity_data["replaced_challenge"] = old_challenge_title
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.REMATCH.value,
data={
"challenge": dropped.challenge.title,
"original_assignment_id": assignment_id,
},
type=ActivityType.SPIN.value, # Treat as a spin activity
data=activity_data,
)
db.add(activity)
await db.commit()
return MessageResponse(message="Rematch started! Complete for 50% points")
class DroppedAssignmentResponse(BaseModel):
id: int
challenge: ChallengeResponse
dropped_at: datetime
class Config:
from_attributes = True
@router.get("/marathons/{marathon_id}/dropped-assignments", response_model=list[DroppedAssignmentResponse])
async def get_dropped_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get dropped assignments that can be rematched"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
.order_by(Assignment.started_at.desc())
)
dropped = result.scalars().all()
return [
DroppedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
dropped_at=a.completed_at or a.started_at,
)
for a in dropped
]
if old_challenge_title:
return MessageResponse(message=f"Задание заменено! Теперь у вас: {challenge.title}")
else:
return MessageResponse(message=f"Задание выбрано: {challenge.title}")
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
@@ -864,3 +912,262 @@ async def get_common_enemy_leaderboard(
)
return leaderboard
# ==================== Event Assignment Endpoints ====================
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
"""Convert Assignment model to AssignmentResponse"""
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
status=assignment.status,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
@router.get("/marathons/{marathon_id}/event-assignment", response_model=EventAssignmentResponse)
async def get_event_assignment(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's event assignment (Common Enemy)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Get active common enemy event
event = await event_service.get_active_event(db, marathon_id)
# Find event assignment for this participant
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.is_event_assignment == True,
)
.order_by(Assignment.started_at.desc())
)
assignment = result.scalar_one_or_none()
# Check if completed
is_completed = assignment.status == AssignmentStatus.COMPLETED.value if assignment else False
# If no active event but we have an assignment, it might be from a past event
# Only return it if the event is still active
if not event or event.type != EventType.COMMON_ENEMY.value:
# Check if assignment belongs to an inactive event
if assignment and assignment.event_id:
result = await db.execute(
select(Event).where(Event.id == assignment.event_id)
)
assignment_event = result.scalar_one_or_none()
if assignment_event and not assignment_event.is_active:
# Event ended, don't return the assignment
return EventAssignmentResponse(
assignment=None,
event_id=None,
challenge_id=None,
is_completed=False,
)
return EventAssignmentResponse(
assignment=assignment_to_response(assignment) if assignment else None,
event_id=event.id if event else None,
challenge_id=event.data.get("challenge_id") if event and event.data else None,
is_completed=is_completed,
)
@router.post("/event-assignments/{assignment_id}/complete", response_model=CompleteResult)
async def complete_event_assignment(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an event assignment (Common Enemy) with proof"""
from app.services.points import PointsService
points_service = PointsService()
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Must be event assignment
if not assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="This is not an event assignment")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
assignment.proof_path = file_path
else:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Get marathon_id
marathon_id = assignment.challenge.game.marathon_id
# Get active event for bonus calculation
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate base points (no streak bonus for event assignments)
participant = assignment.participant
challenge = assignment.challenge
base_points = challenge.points
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
common_enemy_winners = None
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
db, active_event, participant.id, current_user.id
)
total_points = base_points + common_enemy_bonus
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.completed_at = datetime.utcnow()
# Update participant points (event assignments add to total but don't affect streak)
participant.total_points += total_points
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True,
}
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True,
"winners": winners_data,
},
)
db.add(event_end_activity)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=0, # Event assignments don't give streak bonus
total_points=participant.total_points,
new_streak=participant.current_streak, # Streak unchanged
)

View File

@@ -3,7 +3,8 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant
from app.models import Activity, Participant, Dispute, ActivityType
from app.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic
router = APIRouter(tags=["feed"])
@@ -44,16 +45,40 @@ async def get_feed(
)
activities = result.scalars().all()
items = [
# Get assignment_ids from complete activities to check for disputes
complete_assignment_ids = []
for a in activities:
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
complete_assignment_ids.append(a.data["assignment_id"])
# Get disputes for these assignments
disputes_map: dict[int, str] = {}
if complete_assignment_ids:
result = await db.execute(
select(Dispute).where(Dispute.assignment_id.in_(complete_assignment_ids))
)
for dispute in result.scalars().all():
disputes_map[dispute.assignment_id] = dispute.status
items = []
for a in activities:
data = dict(a.data) if a.data else {}
# Add dispute status to complete activities
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
assignment_id = a.data["assignment_id"]
if assignment_id in disputes_map:
data["dispute_status"] = disputes_map[assignment_id]
items.append(
ActivityResponse(
id=a.id,
type=a.type,
user=UserPublic.model_validate(a.user),
data=a.data,
data=data if data else None,
created_at=a.created_at,
)
for a in activities
]
)
return FeedResponse(
items=items,

View File

@@ -1,8 +1,6 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import (
DbSession, CurrentUser,
@@ -11,6 +9,7 @@ from app.api.deps import (
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
router = APIRouter(tags=["games"])
@@ -35,7 +34,7 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
return GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
status=game.status,
@@ -354,15 +353,20 @@ async def upload_cover(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Save file
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
# Delete old cover if exists
if game.cover_path:
await storage_service.delete_file(game.cover_path)
with open(filepath, "wb") as f:
f.write(contents)
# Upload file
filename = storage_service.generate_filename(game_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
game.cover_path = str(filepath)
game.cover_path = file_path
await db.commit()
return await get_game(game_id, current_user, db)

View File

@@ -27,6 +27,7 @@ from app.schemas import (
UserPublic,
SetParticipantRole,
)
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/marathons", tags=["marathons"])
@@ -294,6 +295,9 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)
@@ -319,6 +323,9 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)

View File

@@ -0,0 +1,393 @@
import logging
from fastapi import APIRouter
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.core.security import create_telegram_link_token, verify_telegram_link_token
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/telegram", tags=["telegram"])
# Schemas
class TelegramLinkToken(BaseModel):
token: str
bot_url: str
class TelegramConfirmLink(BaseModel):
token: str
telegram_id: int
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
class TelegramLinkResponse(BaseModel):
success: bool
nickname: str | None = None
error: str | None = None
class TelegramUserResponse(BaseModel):
id: int
nickname: str
login: str
avatar_url: str | None = None
class Config:
from_attributes = True
class TelegramMarathonResponse(BaseModel):
id: int
title: str
status: str
total_points: int
position: int
class Config:
from_attributes = True
class TelegramMarathonDetails(BaseModel):
marathon: dict
participant: dict
position: int
active_events: list[dict]
current_assignment: dict | None
class TelegramStatsResponse(BaseModel):
marathons_completed: int
marathons_active: int
challenges_completed: int
total_points: int
best_streak: int
# Endpoints
@router.post("/generate-link-token", response_model=TelegramLinkToken)
async def generate_link_token(current_user: CurrentUser):
"""Generate a one-time token for Telegram account linking."""
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
# Create a short token (≤64 chars) for Telegram deep link
token = create_telegram_link_token(
user_id=current_user.id,
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
)
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
return TelegramLinkToken(token=token, bot_url=bot_url)
@router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
"""Confirm Telegram account linking (called by bot)."""
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
logger.info(f"[TG_CONFIRM] token: {data.token}")
# Verify short token and extract user_id
user_id = verify_telegram_link_token(data.token)
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
if user_id is None:
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
# Get user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
if not user:
logger.error(f"[TG_CONFIRM] FAILED: User not found")
return TelegramLinkResponse(success=False, error="Пользователь не найден")
# Check if telegram_id already linked to another user
result = await db.execute(
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
)
existing_user = result.scalar_one_or_none()
if existing_user:
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
return TelegramLinkResponse(
success=False,
error="Этот Telegram аккаунт уже привязан к другому пользователю"
)
# Link account
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
user.telegram_id = data.telegram_id
user.telegram_username = data.telegram_username
user.telegram_first_name = data.telegram_first_name
user.telegram_last_name = data.telegram_last_name
user.telegram_avatar_url = data.telegram_avatar_url
await db.commit()
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
return TelegramLinkResponse(success=True, nickname=user.nickname)
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
"""Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
return None
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
return TelegramUserResponse(
id=user.id,
nickname=user.nickname,
login=user.login,
avatar_url=user.avatar_url
)
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession):
"""Unlink Telegram account."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
user.telegram_id = None
user.telegram_username = None
await db.commit()
return TelegramLinkResponse(success=True)
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession):
"""Get user's marathons by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return []
# Get participations with marathons
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
.order_by(Marathon.created_at.desc())
)
participations = result.all()
marathons = []
for participant, marathon in participations:
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon.id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
marathons.append(TelegramMarathonResponse(
id=marathon.id,
title=marathon.title,
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
total_points=participant.total_points,
position=position
))
return marathons
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
"""Get marathon details for user by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
return None
# Get participant
result = await db.execute(
select(Participant)
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
)
participant = result.scalar_one_or_none()
if not participant:
return None
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon_id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
# Get active events
result = await db.execute(
select(Event)
.where(Event.marathon_id == marathon_id, Event.is_active == True)
)
active_events = result.scalars().all()
events_data = [
{
"id": e.id,
"type": e.type.value if hasattr(e.type, 'value') else e.type,
"start_time": e.start_time.isoformat() if e.start_time else None,
"end_time": e.end_time.isoformat() if e.end_time else None
}
for e in active_events
]
# Get current assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == "active"
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
assignment = result.scalar_one_or_none()
assignment_data = None
if assignment:
challenge = assignment.challenge
game = challenge.game if challenge else None
assignment_data = {
"id": assignment.id,
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
"challenge": {
"id": challenge.id if challenge else None,
"title": challenge.title if challenge else None,
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
"points": challenge.points if challenge else None,
"game": {
"id": game.id if game else None,
"title": game.title if game else None
}
} if challenge else None
}
return TelegramMarathonDetails(
marathon={
"id": marathon.id,
"title": marathon.title,
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
"description": marathon.description
},
participant={
"total_points": participant.total_points,
"current_streak": participant.current_streak,
"drop_count": participant.drop_count
},
position=position,
active_events=events_data,
current_assignment=assignment_data
)
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession):
"""Get user's overall statistics by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get participations
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
)
participations = result.all()
marathons_completed = 0
marathons_active = 0
total_points = 0
best_streak = 0
for participant, marathon in participations:
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
if status == "finished":
marathons_completed += 1
elif status == "active":
marathons_active += 1
total_points += participant.total_points
if participant.current_streak > best_streak:
best_streak = participant.current_streak
# Count completed assignments
result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Assignment.participant_id == Participant.id)
.where(Participant.user_id == user.id, Assignment.status == "completed")
)
challenges_completed = result.scalar() or 0
return TelegramStatsResponse(
marathons_completed=marathons_completed,
marathons_active=marathons_active,
challenges_completed=challenges_completed,
total_points=total_points,
best_streak=best_streak
)

View File

@@ -1,12 +1,11 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import User
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
from app.services.storage import storage_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -64,16 +63,21 @@ async def upload_avatar(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Save file
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
# Delete old avatar if exists
if current_user.avatar_path:
await storage_service.delete_file(current_user.avatar_path)
with open(filepath, "wb") as f:
f.write(contents)
# Upload file
filename = storage_service.generate_filename(current_user.id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="avatars",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update user
current_user.avatar_path = str(filepath)
current_user.avatar_path = file_path
await db.commit()
await db.refresh(current_user)
@@ -102,3 +106,22 @@ async def link_telegram(
await db.commit()
return MessageResponse(message="Telegram account linked successfully")
@router.post("/me/telegram/unlink", response_model=MessageResponse)
async def unlink_telegram(
current_user: CurrentUser,
db: DbSession,
):
if not current_user.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Telegram account is not linked",
)
current_user.telegram_id = None
current_user.telegram_username = None
await db.commit()
return MessageResponse(message="Telegram account unlinked successfully")

View File

@@ -3,15 +3,13 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty
EventType, Difficulty, User
)
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
@@ -19,6 +17,7 @@ from app.schemas import (
)
from app.services.points import PointsService
from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["wheel"])
@@ -38,7 +37,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
return participant
async def get_active_assignment(db, participant_id: int) -> Assignment | None:
async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None:
"""Get active assignment for participant.
Args:
db: Database session
participant_id: Participant ID
is_event: If True, get event assignment (Common Enemy). If False, get regular assignment.
"""
result = await db.execute(
select(Assignment)
.options(
@@ -47,11 +53,45 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == is_event,
)
)
return result.scalar_one_or_none()
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
"""Get the oldest returned assignment that needs to be redone."""
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.RETURNED.value,
Assignment.is_event_assignment == False,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
.limit(1)
)
return result.scalar_one_or_none()
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
"""
Re-activate a returned assignment.
Simply changes the status back to ACTIVE.
"""
returned_assignment.status = AssignmentStatus.ACTIVE.value
returned_assignment.started_at = datetime.utcnow()
# Clear previous proof data for fresh attempt
returned_assignment.proof_path = None
returned_assignment.proof_url = None
returned_assignment.proof_comment = None
returned_assignment.completed_at = None
returned_assignment.points_earned = 0
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge"""
@@ -64,10 +104,14 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
# Check if marathon has expired by end_date
if marathon.end_date and datetime.utcnow() > marathon.end_date:
raise HTTPException(status_code=400, detail="Marathon has ended")
participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active assignment
active = await get_active_assignment(db, participant.id)
# Check no active regular assignment (event assignments are separate)
active = await get_active_assignment(db, participant.id, is_event=False)
if active:
raise HTTPException(status_code=400, detail="You already have an active assignment")
@@ -77,7 +121,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game = None
challenge = None
# Handle special event cases
# Handle special event cases (excluding Common Enemy - it has separate flow)
if active_event:
if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only
@@ -90,17 +134,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game = result.scalar_one_or_none()
# Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id)
elif active_event.type == EventType.COMMON_ENEMY.value:
# Common enemy: Everyone gets same challenge (if not already completed)
event_data = active_event.data or {}
completions = event_data.get("completions", [])
already_completed = any(c["participant_id"] == participant.id for c in completions)
if not already_completed:
challenge = await event_service.get_common_enemy_challenge(db, active_event)
if challenge:
game = challenge.game
# Note: Common Enemy is handled separately via event-assignment endpoints
# Normal random selection if no special event handling
if not game or not challenge:
@@ -130,6 +164,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
activity_data = {
"game": game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
}
if active_event:
activity_data["event_type"] = active_event.type
@@ -146,7 +182,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
# Get challenges count (avoid lazy loading in async context)
challenges_count = 0
@@ -162,7 +198,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game=GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
added_by=None,
@@ -190,9 +226,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Get current active assignment"""
"""Get current active regular assignment (not event assignments)"""
participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id)
assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment:
return None
@@ -200,6 +236,10 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
challenge = assignment.challenge
game = challenge.game
# Calculate drop penalty (considers active event for double_risk)
active_event = await event_service.get_active_event(db, marathon_id)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
@@ -217,12 +257,13 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
created_at=challenge.created_at,
),
status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
)
@@ -235,7 +276,7 @@ async def complete_assignment(
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an assignment with proof"""
"""Complete a regular assignment with proof (not event assignments)"""
# Get assignment
result = await db.execute(
select(Assignment)
@@ -256,6 +297,10 @@ async def complete_assignment(
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Event assignments should be completed via /event-assignments/{id}/complete
if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
@@ -276,14 +321,16 @@ async def complete_assignment(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
# Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
with open(filepath, "wb") as f:
f.write(contents)
assignment.proof_path = str(filepath)
assignment.proof_path = file_path
else:
assignment.proof_url = proof_url
@@ -303,12 +350,12 @@ async def complete_assignment(
# Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot/rematch: use the event_type stored in assignment (since event may be over)
# For jackpot: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event
effective_event = active_event
# Handle assignment-level event types (jackpot, rematch)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
# Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent:
def __init__(self, event_type):
@@ -328,6 +375,7 @@ async def complete_assignment(
db, active_event, participant.id, current_user.id
)
total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -342,12 +390,15 @@ async def complete_assignment(
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"streak": participant.current_streak,
}
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
# Log event info (use assignment's event_type for jackpot, active_event for others)
if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
elif active_event:
@@ -367,6 +418,24 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close
@@ -375,20 +444,20 @@ async def complete_assignment(
"event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True,
"winners": [
{
"user_id": w["user_id"],
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
],
"winners": winners_data,
},
)
db.add(event_end_activity)
await db.commit()
# Check for returned assignments and activate the oldest one
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
@@ -427,7 +496,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -440,7 +509,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Log activity
activity_data = {
"game": assignment.challenge.game.title,
"challenge": assignment.challenge.title,
"difficulty": assignment.challenge.difficulty,
"penalty": penalty,
}
if active_event:
@@ -510,7 +581,7 @@ async def get_my_history(
created_at=a.challenge.created_at,
),
status=a.status,
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url,
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
proof_comment=a.proof_comment,
points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion,

View File

@@ -20,13 +20,25 @@ class Settings(BaseSettings):
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
# Uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
# S3 Storage (FirstVDS)
S3_ENABLED: bool = False
S3_BUCKET_NAME: str = ""
S3_REGION: str = "ru-1"
S3_ACCESS_KEY_ID: str = ""
S3_SECRET_ACCESS_KEY: str = ""
S3_ENDPOINT_URL: str = ""
S3_PUBLIC_URL: str = ""
@property
def ALLOWED_EXTENSIONS(self) -> set:
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS

View File

@@ -1,3 +1,8 @@
import base64
import hashlib
import hmac
import struct
import time
from datetime import datetime, timedelta
from typing import Any
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
return payload
except jwt.JWTError:
return None
def create_telegram_link_token(user_id: int, expire_minutes: int = 10) -> str:
"""
Create a short token for Telegram account linking.
Format: base64url encoded binary data (no separators).
Structure: user_id (4 bytes) + expire_at (4 bytes) + signature (8 bytes) = 16 bytes -> 22 chars base64url.
"""
expire_at = int(time.time()) + (expire_minutes * 60)
# Pack user_id and expire_at as unsigned 32-bit integers (8 bytes total)
data = struct.pack(">II", user_id, expire_at)
# Create HMAC signature (take first 8 bytes)
signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
# Combine data + signature (16 bytes)
token_bytes = data + signature
# Encode as base64url without padding
token = base64.urlsafe_b64encode(token_bytes).decode().rstrip("=")
return token
def verify_telegram_link_token(token: str) -> int | None:
"""
Verify Telegram link token and return user_id if valid.
Returns None if token is invalid or expired.
"""
try:
# Add padding if needed for base64 decoding
padding = 4 - (len(token) % 4)
if padding != 4:
token += "=" * padding
token_bytes = base64.urlsafe_b64decode(token)
if len(token_bytes) != 16:
return None
# Unpack data
data = token_bytes[:8]
provided_signature = token_bytes[8:]
user_id, expire_at = struct.unpack(">II", data)
# Check expiration
if time.time() > expire_at:
return None
# Verify signature
expected_signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
if not hmac.compare_digest(provided_signature, expected_signature):
return None
return user_id
except (ValueError, struct.error, Exception):
return None

View File

@@ -9,6 +9,7 @@ from app.core.config import settings
from app.core.database import engine, Base, async_session_maker
from app.api.v1 import router as api_router
from app.services.event_scheduler import event_scheduler
from app.services.dispute_scheduler import dispute_scheduler
@asynccontextmanager
@@ -23,13 +24,15 @@ async def lifespan(app: FastAPI):
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
# Start event scheduler
# Start schedulers
await event_scheduler.start(async_session_maker)
await dispute_scheduler.start(async_session_maker)
yield
# Shutdown
await event_scheduler.stop()
await dispute_scheduler.stop()
await engine.dispose()

View File

@@ -7,6 +7,7 @@ from app.models.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
__all__ = [
"User",
@@ -30,4 +31,8 @@ __all__ = [
"EventType",
"SwapRequest",
"SwapRequestStatus",
"Dispute",
"DisputeStatus",
"DisputeComment",
"DisputeVote",
]

View File

@@ -19,7 +19,7 @@ class ActivityType(str, Enum):
EVENT_START = "event_start"
EVENT_END = "event_end"
SWAP = "swap"
REMATCH = "rematch"
GAME_CHOICE = "game_choice"
class Activity(Base):

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
DROPPED = "dropped"
RETURNED = "returned" # Disputed and needs to be redone
class Assignment(Base):
@@ -20,6 +21,8 @@ class Assignment(Base):
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -31,3 +34,5 @@ class Assignment(Base):
# Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False)

View File

@@ -0,0 +1,66 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed assignment's proof"""
__tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
class DisputeComment(Base):
"""Comment in a dispute discussion"""
__tablename__ = "dispute_comments"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="comments")
user: Mapped["User"] = relationship("User")
class DisputeVote(Base):
"""Vote in a dispute (valid or invalid)"""
__tablename__ = "dispute_votes"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
vote: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = valid, False = invalid
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Unique constraint: one vote per user per dispute
__table_args__ = (
UniqueConstraint("dispute_id", "user_id", name="uq_dispute_vote_user"),
)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="votes")
user: Mapped["User"] = relationship("User")

View File

@@ -12,7 +12,7 @@ class EventType(str, Enum):
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
JACKPOT = "jackpot" # x3 за сложный челлендж
SWAP = "swap" # обмен заданиями
REMATCH = "rematch" # реванш проваленного
GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор)
class Event(Base):
@@ -37,3 +37,4 @@ class Event(Base):
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
created_by: Mapped["User | None"] = relationship("User")
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")

View File

@@ -21,6 +21,9 @@ class User(Base):
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
telegram_first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@@ -52,5 +55,7 @@ class User(Base):
@property
def avatar_url(self) -> str | None:
if self.avatar_path:
return f"/uploads/avatars/{self.avatar_path.split('/')[-1]}"
# Lazy import to avoid circular dependency
from app.services.storage import storage_service
return storage_service.get_url(self.avatar_path, "avatars")
return None

View File

@@ -41,6 +41,7 @@ from app.schemas.assignment import (
SpinResult,
CompleteResult,
DropResult,
EventAssignmentResponse,
)
from app.schemas.activity import (
ActivityResponse,
@@ -66,6 +67,16 @@ from app.schemas.common import (
ErrorResponse,
PaginationParams,
)
from app.schemas.dispute import (
DisputeCreate,
DisputeCommentCreate,
DisputeVoteCreate,
DisputeCommentResponse,
DisputeVoteResponse,
DisputeResponse,
AssignmentDetailResponse,
ReturnedAssignmentResponse,
)
__all__ = [
# User
@@ -107,6 +118,7 @@ __all__ = [
"SpinResult",
"CompleteResult",
"DropResult",
"EventAssignmentResponse",
# Activity
"ActivityResponse",
"FeedResponse",
@@ -128,4 +140,13 @@ __all__ = [
"MessageResponse",
"ErrorResponse",
"PaginationParams",
# Dispute
"DisputeCreate",
"DisputeCommentCreate",
"DisputeVoteCreate",
"DisputeCommentResponse",
"DisputeVoteResponse",
"DisputeResponse",
"AssignmentDetailResponse",
"ReturnedAssignmentResponse",
]

View File

@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
streak_at_completion: int | None = None
started_at: datetime
completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
class Config:
from_attributes = True
@@ -48,3 +49,14 @@ class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int
class EventAssignmentResponse(BaseModel):
"""Response for event-specific assignment (Common Enemy)"""
assignment: AssignmentResponse | None
event_id: int | None
challenge_id: int | None
is_completed: bool
class Config:
from_attributes = True

View File

@@ -0,0 +1,91 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse
class DisputeCreate(BaseModel):
"""Request to create a dispute"""
reason: str = Field(..., min_length=10, max_length=1000)
class DisputeCommentCreate(BaseModel):
"""Request to add a comment to a dispute"""
text: str = Field(..., min_length=1, max_length=500)
class DisputeVoteCreate(BaseModel):
"""Request to vote on a dispute"""
vote: bool # True = valid (proof is OK), False = invalid (proof is not OK)
class DisputeCommentResponse(BaseModel):
"""Comment in a dispute discussion"""
id: int
user: UserPublic
text: str
created_at: datetime
class Config:
from_attributes = True
class DisputeVoteResponse(BaseModel):
"""Vote in a dispute"""
user: UserPublic
vote: bool # True = valid, False = invalid
created_at: datetime
class Config:
from_attributes = True
class DisputeResponse(BaseModel):
"""Full dispute information"""
id: int
raised_by: UserPublic
reason: str
status: str # "open", "valid", "invalid"
comments: list[DisputeCommentResponse]
votes: list[DisputeVoteResponse]
votes_valid: int
votes_invalid: int
my_vote: bool | None # Current user's vote, None if not voted
expires_at: datetime
created_at: datetime
resolved_at: datetime | None
class Config:
from_attributes = True
class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute"""
id: int
challenge: ChallengeResponse
participant: UserPublic
status: str
proof_url: str | None # External URL (YouTube, etc.)
proof_image_url: str | None # Uploaded file URL
proof_comment: str | None
points_earned: int
streak_at_completion: int | None
started_at: datetime
completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None
class Config:
from_attributes = True
class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone"""
id: int
challenge: ChallengeResponse
original_completed_at: datetime
dispute_reason: str
class Config:
from_attributes = True

View File

@@ -13,7 +13,7 @@ EventTypeLiteral = Literal[
"double_risk",
"jackpot",
"swap",
"rematch",
"game_choice",
]
@@ -32,7 +32,7 @@ class EventCreate(BaseModel):
class EventEffects(BaseModel):
points_multiplier: float = 1.0
drop_free: bool = False
special_action: str | None = None # "swap", "rematch"
special_action: str | None = None # "swap", "game_choice"
description: str = ""
@@ -85,7 +85,7 @@ EVENT_INFO = {
"drop_free": False,
},
EventType.DOUBLE_RISK: {
"name": "Двойной риск",
"name": "Безопасная игра",
"description": "Дропы бесплатны, но очки x0.5",
"default_duration": 120,
"points_multiplier": 0.5,
@@ -106,13 +106,13 @@ EVENT_INFO = {
"drop_free": False,
"special_action": "swap",
},
EventType.REMATCH: {
"name": "Реванш",
"description": "Можно переделать проваленный челлендж за 50% очков",
"default_duration": 240,
"points_multiplier": 0.5,
"drop_free": False,
"special_action": "rematch",
EventType.GAME_CHOICE: {
"name": "Выбор игры",
"description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!",
"default_duration": 120,
"points_multiplier": 1.0,
"drop_free": True, # Free replacement of current assignment
"special_action": "game_choice",
},
}

View File

@@ -33,6 +33,11 @@ class UserPublic(UserBase):
login: str
avatar_url: str | None = None
role: str = "user"
telegram_id: int | None = None
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
created_at: datetime
class Config:

View File

@@ -0,0 +1,89 @@
"""
Dispute Scheduler for automatic dispute resolution after 24 hours.
"""
import asyncio
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service
# Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
class DisputeScheduler:
"""Background scheduler for automatic dispute resolution."""
def __init__(self):
self._running = False
self._task: asyncio.Task | None = None
async def start(self, session_factory) -> None:
"""Start the scheduler background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop(session_factory))
print("[DisputeScheduler] Started")
async def stop(self) -> None:
"""Stop the scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
print("[DisputeScheduler] Stopped")
async def _run_loop(self, session_factory) -> None:
"""Main scheduler loop."""
while self._running:
try:
async with session_factory() as db:
await self._process_expired_disputes(db)
except Exception as e:
print(f"[DisputeScheduler] Error in loop: {e}")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_expired_disputes(self, db: AsyncSession) -> None:
"""Process and resolve expired disputes."""
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
# Find all open disputes that have expired
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
expired_disputes = result.scalars().all()
for dispute in expired_disputes:
try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
db, dispute.id
)
print(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
)
except Exception as e:
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
# Global scheduler instance
dispute_scheduler = DisputeScheduler()

View File

@@ -0,0 +1,149 @@
"""
Dispute resolution service.
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
Dispute, DisputeStatus, DisputeVote,
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
)
from app.services.telegram_notifier import telegram_notifier
class DisputeService:
"""Service for dispute resolution logic"""
async def resolve_dispute(self, db: AsyncSession, dispute_id: int) -> tuple[str, int, int]:
"""
Resolve a dispute based on votes.
Returns:
Tuple of (result_status, votes_valid, votes_invalid)
"""
# Get dispute with votes and assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise ValueError(f"Dispute {dispute_id} not found")
if dispute.status != DisputeStatus.OPEN.value:
raise ValueError(f"Dispute {dispute_id} is already resolved")
# Count votes
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
# Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED
result_status = DisputeStatus.RESOLVED_INVALID.value
await self._handle_invalid_proof(db, dispute)
else:
# Proof is valid (or tie)
result_status = DisputeStatus.RESOLVED_VALID.value
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Send Telegram notification about dispute resolution
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
return result_status, votes_valid, votes_invalid
async def _notify_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_valid: bool
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge and marathon info
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(Assignment.id == dispute.assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
return
participant = assignment.participant
challenge = assignment.challenge
game = challenge.game if challenge else None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
)
marathon = result.scalar_one_or_none()
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown",
is_valid=is_valid
)
except Exception as e:
print(f"[DisputeService] Failed to send notification: {e}")
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when proof is determined to be invalid.
- Mark assignment as RETURNED
- Subtract points from participant
- Reset streak if it was affected
"""
assignment = dispute.assignment
participant = assignment.participant
# Subtract points that were earned
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# Keep proof data so it can be reviewed
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
f"subtracted {points_to_subtract} points from participant {participant.id}")
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours"""
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
return list(result.scalars().all())
# Global service instance
dispute_service = DisputeService()

View File

@@ -21,7 +21,7 @@ AUTO_EVENT_TYPES = [
EventType.GOLDEN_HOUR,
EventType.DOUBLE_RISK,
EventType.JACKPOT,
EventType.REMATCH,
EventType.GAME_CHOICE,
]

View File

@@ -4,8 +4,9 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Event, EventType, Marathon, Challenge, Difficulty
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
from app.services.telegram_notifier import telegram_notifier
class EventService:
@@ -76,6 +77,12 @@ class EventService:
data=data if data else None,
)
db.add(event)
await db.flush() # Get event.id before committing
# Auto-assign challenge to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
await db.commit()
await db.refresh(event)
@@ -83,18 +90,81 @@ class EventService:
if created_by_id:
await db.refresh(event, ["created_by"])
# Send Telegram notifications
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_start(
db, marathon_id, event_type, marathon.title
)
return event
async def _assign_common_enemy_to_all(
self,
db: AsyncSession,
marathon_id: int,
event_id: int,
challenge_id: int,
) -> None:
"""Create event assignments for all participants in the marathon"""
# Get all participants
result = await db.execute(
select(Participant).where(Participant.marathon_id == marathon_id)
)
participants = result.scalars().all()
# Create event assignment for each participant
for participant in participants:
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge_id,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.COMMON_ENEMY.value,
is_event_assignment=True,
event_id=event_id,
)
db.add(assignment)
async def end_event(self, db: AsyncSession, event_id: int) -> None:
"""End an event"""
"""End an event and mark incomplete event assignments as expired"""
from sqlalchemy import update
result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if event:
event_type = event.type
marathon_id = event.marathon_id
event.is_active = False
if not event.end_time:
event.end_time = datetime.utcnow()
# Mark all incomplete event assignments for this event as dropped
if event.type == EventType.COMMON_ENEMY.value:
await db.execute(
update(Assignment)
.where(
Assignment.event_id == event_id,
Assignment.is_event_assignment == True,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
.values(
status=AssignmentStatus.DROPPED.value,
completed_at=datetime.utcnow(),
)
)
await db.commit()
# Send Telegram notifications about event end
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_end(
db, marathon_id, event_type, marathon.title
)
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
"""Consume jackpot event after one spin"""
await self.end_event(db, event_id)
@@ -157,13 +227,16 @@ class EventService:
- winners_list: list of winners if event closed, None otherwise
"""
if event.type != EventType.COMMON_ENEMY.value:
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
return 0, False, None
data = event.data or {}
completions = data.get("completions", [])
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
# Check if already completed
if any(c["participant_id"] == participant_id for c in completions):
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
return 0, False, None
# Add completion
@@ -174,6 +247,7 @@ class EventService:
"completed_at": datetime.utcnow().isoformat(),
"rank": rank,
})
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
event.data = {**data, "completions": completions}
@@ -189,6 +263,7 @@ class EventService:
event.end_time = datetime.utcnow()
event_closed = True
winners_list = completions[:3] # Top 3
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
await db.commit()

View File

@@ -28,33 +28,43 @@ class GPTService:
"""
genre_text = f" (жанр: {game_genre})" if game_genre else ""
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона.
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй 6 КОНКРЕТНЫХ челленджей для игры "{game_title}"{genre_text}.
Требования:
- 2 лёгких челленджа (15-30 минут игры)
- 2 средних челленджа (1-2 часа игры)
- 2 сложных челленджа (3+ часов или высокая сложность)
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для этой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре (спидраны, no-hit боссов, сбор коллекционных предметов и т.д.)
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
Для каждого челленджа укажи:
- title: короткое название на русском (до 50 символов)
- description: что нужно сделать на русском (1-2 предложения)
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
- difficulty: easy/medium/hard
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
- estimated_time: примерное время в минутах
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
Примеры ХОРОШИХ челленджей:
- Dark Souls: "Победи Орнштейна и Смоуга без призыва" / "Пройди Чумной город без отравления"
- GTA V: "Получи золото в миссии «Ювелирное дело»" / "Выиграй уличную гонку на Vinewood"
- Hollow Knight: "Победи Хорнет без получения урона" / "Найди все грибные споры в Грибных пустошах"
- Minecraft: "Убей Дракона Края за один визит в Энд" / "Построй работающую ферму железа"
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей.
Пример формата:
Требования по сложности:
- 2 лёгких (15-30 мин): простые задачи, знакомство с игрой
- 2 средних (1-2 часа): требуют навыка или исследования
- 2 сложных (3+ часа): серьёзный челлендж, достижения, полное прохождение
Формат ответа — JSON:
- title: название на русском (до 50 символов), конкретное и понятное
- description: что именно сделать (1-2 предложения), с деталями из игры
- type: completion | no_death | speedrun | collection | achievement | challenge_run
- difficulty: easy | medium | hard
- points: easy=20-40, medium=45-75, hard=90-150
- estimated_time: время в минутах
- proof_type: screenshot | video | steam
- proof_hint: ЧТО КОНКРЕТНО должно быть видно на скриншоте/видео (экран победы, достижение, локация и т.д.)
Ответь ТОЛЬКО JSON:
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
response = await self.client.chat.completions.create(
model="gpt-4o-mini",
model="gpt-5-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.7,
max_tokens=2000,
temperature=0.8,
max_tokens=2500,
)
content = response.choices[0].message.content
@@ -77,10 +87,17 @@ class GPTService:
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
# Validate points
points = ch.get("points", 50)
# Validate points based on difficulty
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 50
points = 30
# Clamp points to expected ranges
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
challenges.append(ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],

View File

@@ -13,19 +13,19 @@ class PointsService:
}
MAX_STREAK_MULTIPLIER = 0.4
DROP_PENALTIES = {
0: 0, # First drop is free
1: 10,
2: 25,
# Drop penalty as percentage of challenge points
DROP_PENALTY_PERCENTAGES = {
0: 0.5, # 1st drop: 50%
1: 0.75, # 2nd drop: 75%
}
MAX_DROP_PENALTY = 50
MAX_DROP_PENALTY_PERCENTAGE = 1.0 # 3rd+ drop: 100%
# Event point multipliers
EVENT_MULTIPLIERS = {
EventType.GOLDEN_HOUR.value: 1.5,
EventType.DOUBLE_RISK.value: 0.5,
EventType.JACKPOT.value: 3.0,
EventType.REMATCH.value: 0.5,
# GAME_CHOICE uses 1.0 multiplier (default)
}
def calculate_completion_points(
@@ -66,6 +66,7 @@ class PointsService:
def calculate_drop_penalty(
self,
consecutive_drops: int,
challenge_points: int,
event: Event | None = None
) -> int:
"""
@@ -73,6 +74,7 @@ class PointsService:
Args:
consecutive_drops: Number of drops since last completion
challenge_points: Base points of the challenge being dropped
event: Active event (optional)
Returns:
@@ -82,10 +84,11 @@ class PointsService:
if event and event.type == EventType.DOUBLE_RISK.value:
return 0
return self.DROP_PENALTIES.get(
penalty_percentage = self.DROP_PENALTY_PERCENTAGES.get(
consecutive_drops,
self.MAX_DROP_PENALTY
self.MAX_DROP_PENALTY_PERCENTAGE
)
return int(challenge_points * penalty_percentage)
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
"""Apply event multiplier to points"""

View File

@@ -0,0 +1,269 @@
"""
Storage service for file uploads.
Supports both local filesystem and S3-compatible storage (FirstVDS).
"""
import logging
import uuid
from pathlib import Path
from typing import Literal
import boto3
from botocore.exceptions import ClientError, BotoCoreError
from botocore.config import Config
from app.core.config import settings
logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"]
class StorageService:
"""Unified storage service with S3 and local filesystem support."""
def __init__(self):
self._s3_client = None
@property
def s3_client(self):
"""Lazy initialization of S3 client."""
if self._s3_client is None and settings.S3_ENABLED:
logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}")
try:
# Use signature_version=s3v4 for S3-compatible storage
self._s3_client = boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT_URL,
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
region_name=settings.S3_REGION or "us-east-1",
config=Config(signature_version="s3v4"),
)
logger.info("S3 client initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize S3 client: {e}")
self._s3_client = None
return self._s3_client
def generate_filename(self, prefix: str | int, original_filename: str | None) -> str:
"""Generate unique filename with prefix."""
ext = "jpg"
if original_filename and "." in original_filename:
ext = original_filename.rsplit(".", 1)[-1].lower()
return f"{prefix}_{uuid.uuid4().hex}.{ext}"
async def upload_file(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str = "application/octet-stream",
) -> str:
"""
Upload file to storage.
Returns:
Path/key to the uploaded file (relative path for local, S3 key for S3)
"""
if settings.S3_ENABLED:
try:
return await self._upload_to_s3(content, folder, filename, content_type)
except Exception as e:
logger.error(f"S3 upload failed, falling back to local: {e}")
return await self._upload_to_local(content, folder, filename)
else:
return await self._upload_to_local(content, folder, filename)
async def _upload_to_s3(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str,
) -> str:
"""Upload file to S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
raise RuntimeError("S3 client not initialized")
try:
logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}")
self.s3_client.put_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
Body=content,
ContentType=content_type,
)
logger.info(f"Successfully uploaded to S3: {key}")
return key
except (ClientError, BotoCoreError) as e:
logger.error(f"S3 upload error: {e}")
raise RuntimeError(f"Failed to upload to S3: {e}")
async def _upload_to_local(
self,
content: bytes,
folder: StorageFolder,
filename: str,
) -> str:
"""Upload file to local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(content)
return str(filepath)
def get_url(self, path: str | None, folder: StorageFolder) -> str | None:
"""
Get public URL for a file.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder (avatars, covers, proofs)
Returns:
Public URL or None if path is None
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
# S3 URL
return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}"
else:
# Local URL
return f"/uploads/{folder}/{filename}"
async def delete_file(self, path: str | None) -> bool:
"""
Delete file from storage.
Args:
path: File path/key
Returns:
True if deleted, False otherwise
"""
if not path:
return False
if settings.S3_ENABLED:
return await self._delete_from_s3(path)
else:
return await self._delete_from_local(path)
async def _delete_from_s3(self, key: str) -> bool:
"""Delete file from S3."""
try:
self.s3_client.delete_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
return True
except ClientError:
return False
async def _delete_from_local(self, path: str) -> bool:
"""Delete file from local filesystem."""
try:
filepath = Path(path)
if filepath.exists():
filepath.unlink()
return True
return False
except Exception:
return False
async def get_file(
self,
path: str,
folder: StorageFolder,
) -> tuple[bytes, str] | None:
"""
Get file content from storage.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder
Returns:
Tuple of (content bytes, content_type) or None if not found
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
return await self._get_from_s3(folder, filename)
else:
return await self._get_from_local(folder, filename)
async def _get_from_s3(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
logger.error("S3 client not initialized")
return None
try:
response = self.s3_client.get_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
content = response["Body"].read()
content_type = response.get("ContentType", "application/octet-stream")
return content, content_type
except ClientError as e:
logger.error(f"S3 get error for {key}: {e}")
return None
async def _get_from_local(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
if not filepath.exists():
return None
try:
with open(filepath, "rb") as f:
content = f.read()
# Determine content type from extension
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
content_types = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"mp4": "video/mp4",
"webm": "video/webm",
"mov": "video/quicktime",
}
content_type = content_types.get(ext, "application/octet-stream")
return content, content_type
except Exception as e:
logger.error(f"Local get error for {filepath}: {e}")
return None
# Singleton instance
storage_service = StorageService()

View File

@@ -0,0 +1,212 @@
import logging
from typing import List
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import User, Participant, Marathon
logger = logging.getLogger(__name__)
class TelegramNotifier:
"""Service for sending Telegram notifications."""
def __init__(self):
self.bot_token = settings.TELEGRAM_BOT_TOKEN
self.api_url = f"https://api.telegram.org/bot{self.bot_token}"
async def send_message(
self,
chat_id: int,
text: str,
parse_mode: str = "HTML"
) -> bool:
"""Send a message to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}/sendMessage",
json={
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
},
timeout=10.0
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send message: {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram message: {e}")
return False
async def notify_user(
self,
db: AsyncSession,
user_id: int,
message: str
) -> bool:
"""Send notification to a user by user_id."""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user or not user.telegram_id:
return False
return await self.send_message(user.telegram_id, message)
async def notify_marathon_participants(
self,
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
) -> int:
"""Send notification to all marathon participants with linked Telegram."""
result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
.where(
Participant.marathon_id == marathon_id,
User.telegram_id.isnot(None)
)
)
users = result.scalars().all()
sent_count = 0
for user in users:
if exclude_user_id and user.id == exclude_user_id:
continue
if await self.send_message(user.telegram_id, message):
sent_count += 1
return sent_count
# Notification templates
async def notify_event_start(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
"double_risk": f"⚡ <b>Double Risk</b> в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!",
"common_enemy": f"👥 <b>Common Enemy</b> в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!",
"swap": f"🔄 <b>Swap</b> в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!",
"game_choice": f"🎲 <b>Выбор игры</b> в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!"
}
message = event_messages.get(
event_type,
f"📌 Новое событие в «{marathon_title}»!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_event_end(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
"double_risk": "Double Risk",
"common_enemy": "Common Enemy",
"swap": "Swap",
"game_choice": "Выбор игры"
}
event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_start(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon start."""
message = (
f"🚀 <b>Марафон «{marathon_title}» начался!</b>\n\n"
f"Время крутить колесо и получить первое задание!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_finish(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon finish."""
message = (
f"🏆 <b>Марафон «{marathon_title}» завершён!</b>\n\n"
f"Зайди на сайт, чтобы увидеть итоговую таблицу!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_dispute_raised(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str
) -> bool:
"""Notify user about dispute raised on their assignment."""
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Зайди на сайт, чтобы ответить на спор."
)
return await self.notify_user(db, user_id, message)
async def notify_dispute_resolved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str,
is_valid: bool
) -> bool:
"""Notify user about dispute resolution."""
if is_valid:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Задание возвращено. Выполни его заново."
)
else:
message = (
f"✅ <b>Спор отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Твоё выполнение засчитано!"
)
return await self.notify_user(db, user_id, message)
# Global instance
telegram_notifier = TelegramNotifier()

View File

@@ -28,5 +28,8 @@ httpx==0.26.0
aiofiles==23.2.1
python-magic==0.4.27
# S3 Storage
boto3==1.34.0
# Utils
python-dotenv==1.0.0

10
bot/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

14
bot/config.py Normal file
View File

@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

1
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot handlers

60
bot/handlers/link.py Normal file
View File

@@ -0,0 +1,60 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
router = Router()
@router.message(Command("unlink"))
async def cmd_unlink(message: Message):
"""Handle /unlink command to disconnect Telegram account."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Твой аккаунт не привязан к Game Marathon.\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
result = await api_client.unlink_telegram(message.from_user.id)
if result.get("success"):
await message.answer(
"<b>Аккаунт отвязан</b>\n\n"
"Ты больше не будешь получать уведомления.\n"
"Чтобы привязать аккаунт снова, используй кнопку в настройках профиля на сайте.",
reply_markup=get_main_menu()
)
else:
await message.answer(
"Произошла ошибка при отвязке аккаунта.\n"
"Попробуй позже или обратись к администратору.",
reply_markup=get_main_menu()
)
@router.message(Command("status"))
async def cmd_status(message: Message):
"""Check account link status."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if user:
await message.answer(
f"<b>Статус аккаунта</b>\n\n"
f"✅ Аккаунт привязан\n"
f"👤 Никнейм: <b>{user.get('nickname', 'N/A')}</b>\n"
f"🆔 ID: {user.get('id', 'N/A')}",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Статус аккаунта</b>\n\n"
"❌ Аккаунт не привязан\n\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)

211
bot/handlers/marathons.py Normal file
View File

@@ -0,0 +1,211 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
from services.api_client import api_client
router = Router()
@router.message(Command("marathons"))
@router.message(F.text == "📊 Мои марафоны")
async def cmd_marathons(message: Message):
"""Show user's marathons."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
marathons = await api_client.get_user_marathons(message.from_user.id)
if not marathons:
await message.answer(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов.\n"
"Присоединись к марафону на сайте!",
reply_markup=get_main_menu()
)
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await message.answer(
text,
reply_markup=get_marathons_keyboard(marathons)
)
@router.callback_query(F.data.startswith("marathon:"))
async def marathon_details(callback: CallbackQuery):
"""Show marathon details."""
marathon_id = int(callback.data.split(":")[1])
details = await api_client.get_marathon_details(
marathon_id=marathon_id,
telegram_id=callback.from_user.id
)
if not details:
await callback.answer("Не удалось загрузить данные марафона", show_alert=True)
return
marathon = details.get("marathon", {})
participant = details.get("participant", {})
active_events = details.get("active_events", [])
current_assignment = details.get("current_assignment")
status_text = {
"preparing": "⏳ Подготовка",
"active": "🎮 Активен",
"finished": "🏁 Завершён"
}.get(marathon.get("status"), "")
text = f"<b>{marathon.get('title')}</b>\n"
text += f"Статус: {status_text}\n\n"
text += f"<b>📈 Твоя статистика:</b>\n"
text += f"• Очки: <b>{participant.get('total_points', 0)}</b>\n"
text += f"• Место: <b>#{details.get('position', '?')}</b>\n"
text += f"• Стрик: <b>{participant.get('current_streak', 0)}</b> 🔥\n"
text += f"• Дропов: <b>{participant.get('drop_count', 0)}</b>\n\n"
if active_events:
text += "<b>⚡ Активные события:</b>\n"
for event in active_events:
event_emoji = {
"golden_hour": "🌟",
"jackpot": "🎰",
"double_risk": "",
"common_enemy": "👥",
"swap": "🔄",
"game_choice": "🎲"
}.get(event.get("type"), "📌")
text += f"{event_emoji} {event.get('type', '').replace('_', ' ').title()}\n"
text += "\n"
if current_assignment:
challenge = current_assignment.get("challenge", {})
game = challenge.get("game", {})
text += f"<b>🎯 Текущее задание:</b>\n"
text += f"Игра: {game.get('title', 'N/A')}\n"
text += f"Задание: {challenge.get('title', 'N/A')}\n"
text += f"Сложность: {challenge.get('difficulty', 'N/A')}\n"
text += f"Очки: {challenge.get('points', 0)}\n"
await callback.message.edit_text(
text,
reply_markup=get_marathon_details_keyboard(marathon_id)
)
await callback.answer()
@router.callback_query(F.data == "back_to_marathons")
async def back_to_marathons(callback: CallbackQuery):
"""Go back to marathons list."""
marathons = await api_client.get_user_marathons(callback.from_user.id)
if not marathons:
await callback.message.edit_text(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов."
)
await callback.answer()
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await callback.message.edit_text(
text,
reply_markup=get_marathons_keyboard(marathons)
)
await callback.answer()
@router.message(Command("stats"))
@router.message(F.text == "📈 Статистика")
async def cmd_stats(message: Message):
"""Show user's overall statistics."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
stats = await api_client.get_user_stats(message.from_user.id)
if not stats:
await message.answer(
"<b>📈 Статистика</b>\n\n"
"Пока нет данных для отображения.\n"
"Начни участвовать в марафонах!",
reply_markup=get_main_menu()
)
return
text = f"<b>📈 Общая статистика</b>\n\n"
text += f"👤 <b>{user.get('nickname', 'Игрок')}</b>\n\n"
text += f"🏆 Марафонов завершено: <b>{stats.get('marathons_completed', 0)}</b>\n"
text += f"🎮 Марафонов активно: <b>{stats.get('marathons_active', 0)}</b>\n"
text += f"✅ Заданий выполнено: <b>{stats.get('challenges_completed', 0)}</b>\n"
text += f"💰 Всего очков: <b>{stats.get('total_points', 0)}</b>\n"
text += f"🔥 Лучший стрик: <b>{stats.get('best_streak', 0)}</b>\n"
await message.answer(text, reply_markup=get_main_menu())
@router.message(Command("settings"))
@router.message(F.text == "⚙️ Настройки")
async def cmd_settings(message: Message):
"""Show notification settings."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
await message.answer(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
reply_markup=get_main_menu()
)

146
bot/handlers/start.py Normal file
View File

@@ -0,0 +1,146 @@
import logging
from aiogram import Router, F, Bot
from aiogram.filters import CommandStart, Command, CommandObject
from aiogram.types import Message
from config import settings
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
logger = logging.getLogger(__name__)
router = Router()
async def get_user_avatar_url(bot: Bot, user_id: int) -> str | None:
"""Get user's Telegram profile photo URL."""
try:
photos = await bot.get_user_profile_photos(user_id, limit=1)
if photos.total_count > 0 and photos.photos:
# Get the largest photo (last in the list)
photo = photos.photos[0][-1]
file = await bot.get_file(photo.file_id)
if file.file_path:
return f"https://api.telegram.org/file/bot{settings.TELEGRAM_BOT_TOKEN}/{file.file_path}"
except Exception as e:
logger.warning(f"[START] Could not get user avatar: {e}")
return None
@router.message(CommandStart())
async def cmd_start(message: Message, command: CommandObject):
"""Handle /start command with or without deep link."""
logger.info(f"[START] ==================== START COMMAND ====================")
logger.info(f"[START] Telegram user: id={message.from_user.id}, username=@{message.from_user.username}")
logger.info(f"[START] Full message text: '{message.text}'")
logger.info(f"[START] Deep link args (command.args): '{command.args}'")
# Check if there's a deep link token (for account linking)
token = command.args
if token:
logger.info(f"[START] -------- TOKEN RECEIVED --------")
logger.info(f"[START] Token: {token}")
logger.info(f"[START] Token length: {len(token)} chars")
# Get user's avatar
avatar_url = await get_user_avatar_url(message.bot, message.from_user.id)
logger.info(f"[START] User avatar URL: {avatar_url}")
logger.info(f"[START] -------- CALLING API --------")
logger.info(f"[START] Sending to /telegram/confirm-link:")
logger.info(f"[START] - token: {token}")
logger.info(f"[START] - telegram_id: {message.from_user.id}")
logger.info(f"[START] - telegram_username: {message.from_user.username}")
logger.info(f"[START] - telegram_first_name: {message.from_user.first_name}")
logger.info(f"[START] - telegram_last_name: {message.from_user.last_name}")
logger.info(f"[START] - telegram_avatar_url: {avatar_url}")
result = await api_client.confirm_telegram_link(
token=token,
telegram_id=message.from_user.id,
telegram_username=message.from_user.username,
telegram_first_name=message.from_user.first_name,
telegram_last_name=message.from_user.last_name,
telegram_avatar_url=avatar_url
)
logger.info(f"[START] -------- API RESPONSE --------")
logger.info(f"[START] Response: {result}")
logger.info(f"[START] Success: {result.get('success')}")
if result.get("success"):
user_nickname = result.get("nickname", "пользователь")
logger.info(f"[START] ✅ LINK SUCCESS! User '{user_nickname}' linked to telegram_id={message.from_user.id}")
await message.answer(
f"<b>Аккаунт успешно привязан!</b>\n\n"
f"Привет, <b>{user_nickname}</b>!\n\n"
f"Теперь ты будешь получать уведомления о:\n"
f"• Начале и окончании событий (Golden Hour, Jackpot и др.)\n"
f"• Старте и завершении марафонов\n"
f"• Спорах по твоим заданиям\n\n"
f"Используй меню ниже для навигации:",
reply_markup=get_main_menu()
)
return
else:
error = result.get("error", "Неизвестная ошибка")
logger.error(f"[START] ❌ LINK FAILED!")
logger.error(f"[START] Error: {error}")
logger.error(f"[START] Token was: {token}")
await message.answer(
f"<b>Ошибка привязки аккаунта</b>\n\n"
f"{error}\n\n"
f"Попробуй получить новую ссылку на сайте.",
reply_markup=get_main_menu()
)
return
# No token - regular start
logger.info(f"[START] No token, checking if user is already linked...")
user = await api_client.get_user_by_telegram_id(message.from_user.id)
logger.info(f"[START] API response: {user}")
if user:
await message.answer(
f"<b>С возвращением, {user.get('nickname', 'игрок')}!</b>\n\n"
f"Твой аккаунт привязан. Используй меню для навигации:",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Добро пожаловать в Game Marathon Bot!</b>\n\n"
"Этот бот поможет тебе следить за марафонами и "
"получать уведомления о важных событиях.\n\n"
"<b>Для начала работы:</b>\n"
"1. Зайди на сайт в настройки профиля\n"
"2. Нажми кнопку «Привязать Telegram»\n"
"3. Перейди по полученной ссылке\n\n"
"После привязки ты сможешь:\n"
"• Смотреть свои марафоны\n"
"• Получать уведомления о событиях\n"
"• Следить за статистикой",
reply_markup=get_main_menu()
)
@router.message(Command("help"))
@router.message(F.text == "❓ Помощь")
async def cmd_help(message: Message):
"""Handle /help command."""
await message.answer(
"<b>Справка по командам:</b>\n\n"
"/start - Начать работу с ботом\n"
"/marathons - Мои марафоны\n"
"/stats - Моя статистика\n"
"/settings - Настройки уведомлений\n"
"/help - Эта справка\n\n"
"<b>Уведомления:</b>\n"
"Бот присылает уведомления о:\n"
"• 🌟 Golden Hour - очки x1.5\n"
"• 🎰 Jackpot - очки x3\n"
"• ⚡ Double Risk - половина очков, дропы бесплатны\n"
"• 👥 Common Enemy - общий челлендж\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям",
reply_markup=get_main_menu()
)

View File

@@ -0,0 +1 @@
# Bot keyboards

42
bot/keyboards/inline.py Normal file
View File

@@ -0,0 +1,42 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def get_marathons_keyboard(marathons: list) -> InlineKeyboardMarkup:
"""Create keyboard with marathon buttons."""
buttons = []
for marathon in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(marathon.get("status"), "")
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {marathon.get('title', 'Marathon')}",
callback_data=f"marathon:{marathon.get('id')}"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
"""Create keyboard for marathon details view."""
buttons = [
[
InlineKeyboardButton(
text="🔄 Обновить",
callback_data=f"marathon:{marathon_id}"
)
],
[
InlineKeyboardButton(
text="◀️ Назад к списку",
callback_data="back_to_marathons"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -0,0 +1,21 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
def get_main_menu() -> ReplyKeyboardMarkup:
"""Create main menu keyboard."""
keyboard = [
[
KeyboardButton(text="📊 Мои марафоны"),
KeyboardButton(text="📈 Статистика")
],
[
KeyboardButton(text="⚙️ Настройки"),
KeyboardButton(text="❓ Помощь")
]
]
return ReplyKeyboardMarkup(
keyboard=keyboard,
resize_keyboard=True,
input_field_placeholder="Выбери действие..."
)

65
bot/main.py Normal file
View File

@@ -0,0 +1,65 @@
import asyncio
import logging
import sys
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from config import settings
from handlers import start, marathons, link
from middlewares.logging import LoggingMiddleware
# Configure logging to stdout with DEBUG level
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# Set aiogram logging level
logging.getLogger("aiogram").setLevel(logging.INFO)
async def main():
logger.info("="*50)
logger.info("Starting Game Marathon Bot...")
logger.info(f"API_URL: {settings.API_URL}")
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
logger.info("="*50)
bot = Bot(
token=settings.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
# Get bot username for deep links
bot_info = await bot.get_me()
settings.BOT_USERNAME = bot_info.username
logger.info(f"Bot info: @{settings.BOT_USERNAME} (id={bot_info.id})")
dp = Dispatcher()
# Register middleware
dp.message.middleware(LoggingMiddleware())
logger.info("Logging middleware registered")
# Register routers
logger.info("Registering routers...")
dp.include_router(start.router)
dp.include_router(link.router)
dp.include_router(marathons.router)
logger.info("Routers registered: start, link, marathons")
# Start polling
logger.info("Deleting webhook and starting polling...")
await bot.delete_webhook(drop_pending_updates=True)
logger.info("Polling started! Waiting for messages...")
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1 @@
# Bot middlewares

View File

@@ -0,0 +1,28 @@
import logging
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any]
) -> Any:
logger.info("="*60)
logger.info(f"[MIDDLEWARE] Incoming message from user {event.from_user.id}")
logger.info(f"[MIDDLEWARE] Username: @{event.from_user.username}")
logger.info(f"[MIDDLEWARE] Text: {event.text}")
logger.info(f"[MIDDLEWARE] Message ID: {event.message_id}")
logger.info(f"[MIDDLEWARE] Chat ID: {event.chat.id}")
logger.info("="*60)
result = await handler(event, data)
logger.info(f"[MIDDLEWARE] Handler completed for message {event.message_id}")
return result

5
bot/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
aiogram==3.23.0
aiohttp==3.10.5
pydantic==2.9.2
pydantic-settings==2.5.2
python-dotenv==1.0.1

1
bot/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot services

129
bot/services/api_client.py Normal file
View File

@@ -0,0 +1,129 @@
import logging
from typing import Any
import aiohttp
from config import settings
logger = logging.getLogger(__name__)
class APIClient:
"""HTTP client for backend API communication."""
def __init__(self):
self.base_url = settings.API_URL
self._session: aiohttp.ClientSession | None = None
logger.info(f"[APIClient] Initialized with base_url: {self.base_url}")
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
logger.info("[APIClient] Creating new aiohttp session")
self._session = aiohttp.ClientSession()
return self._session
async def _request(
self,
method: str,
endpoint: str,
**kwargs
) -> dict[str, Any] | None:
"""Make HTTP request to backend API."""
session = await self._get_session()
url = f"{self.base_url}/api/v1{endpoint}"
logger.info(f"[APIClient] {method} {url}")
if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}")
if 'params' in kwargs:
logger.info(f"[APIClient] Request params: {kwargs['params']}")
try:
async with session.request(method, url, **kwargs) as response:
logger.info(f"[APIClient] Response status: {response.status}")
response_text = await response.text()
logger.info(f"[APIClient] Response body: {response_text[:500]}")
if response.status == 200:
import json
return json.loads(response_text)
elif response.status == 404:
logger.warning(f"[APIClient] 404 Not Found")
return None
else:
logger.error(f"[APIClient] API error {response.status}: {response_text}")
return {"error": response_text}
except aiohttp.ClientError as e:
logger.error(f"[APIClient] Request failed: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"[APIClient] Unexpected error: {e}")
return {"error": str(e)}
async def confirm_telegram_link(
self,
token: str,
telegram_id: int,
telegram_username: str | None,
telegram_first_name: str | None = None,
telegram_last_name: str | None = None,
telegram_avatar_url: str | None = None
) -> dict[str, Any]:
"""Confirm Telegram account linking."""
result = await self._request(
"POST",
"/telegram/confirm-link",
json={
"token": token,
"telegram_id": telegram_id,
"telegram_username": telegram_username,
"telegram_first_name": telegram_first_name,
"telegram_last_name": telegram_last_name,
"telegram_avatar_url": telegram_avatar_url
}
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_by_telegram_id(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user by Telegram ID."""
return await self._request("GET", f"/telegram/user/{telegram_id}")
async def unlink_telegram(self, telegram_id: int) -> dict[str, Any]:
"""Unlink Telegram account."""
result = await self._request(
"POST",
f"/telegram/unlink/{telegram_id}"
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_marathons(self, telegram_id: int) -> list[dict[str, Any]]:
"""Get user's marathons."""
result = await self._request("GET", f"/telegram/marathons/{telegram_id}")
if isinstance(result, list):
return result
return result.get("marathons", []) if result else []
async def get_marathon_details(
self,
marathon_id: int,
telegram_id: int
) -> dict[str, Any] | None:
"""Get marathon details for user."""
return await self._request(
"GET",
f"/telegram/marathon/{marathon_id}",
params={"telegram_id": telegram_id}
)
async def get_user_stats(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user's overall statistics."""
return await self._request("GET", f"/telegram/stats/{telegram_id}")
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
# Global API client instance
api_client = APIClient()

View File

@@ -27,7 +27,16 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
DEBUG: ${DEBUG:-false}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
S3_REGION: ${S3_REGION:-ru-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-}
volumes:
- ./backend/uploads:/app/uploads
- ./backend/app:/app/app
@@ -64,5 +73,17 @@ services:
- backend
restart: unless-stopped
bot:
build:
context: ./bot
dockerfile: Dockerfile
container_name: marathon-bot
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
// Layout
import { Layout } from '@/components/layout/Layout'
@@ -15,6 +16,7 @@ import { LobbyPage } from '@/pages/LobbyPage'
import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage'
import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -40,6 +42,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<>
<ToastContainer />
<ConfirmModal />
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
@@ -118,8 +123,18 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="assignments/:id"
element={
<ProtectedRoute>
<AssignmentDetailPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
</>
)
}

View File

@@ -0,0 +1,47 @@
import client from './client'
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
export const assignmentsApi = {
// Get detailed assignment info with proofs and dispute
getDetail: async (assignmentId: number): Promise<AssignmentDetail> => {
const response = await client.get<AssignmentDetail>(`/assignments/${assignmentId}`)
return response.data
},
// Create a dispute against an assignment
createDispute: async (assignmentId: number, reason: string): Promise<Dispute> => {
const response = await client.post<Dispute>(`/assignments/${assignmentId}/dispute`, { reason })
return response.data
},
// Add a comment to a dispute
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
return response.data
},
// Vote on a dispute (true = valid/proof is OK, false = invalid/proof is not OK)
vote: async (disputeId: number, vote: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/disputes/${disputeId}/vote`, { vote })
return response.data
},
// Get current user's returned assignments
getReturnedAssignments: async (marathonId: number): Promise<ReturnedAssignment[]> => {
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
return response.data
},
// Get proof media as blob URL (supports both images and videos)
getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
responseType: 'blob',
})
const contentType = response.headers['content-type'] || ''
const isVideo = contentType.startsWith('video/')
return {
url: URL.createObjectURL(response.data),
type: isVideo ? 'video' : 'image',
}
},
}

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types'
export const eventsApi = {
getActive: async (marathonId: number): Promise<ActiveEvent> => {
@@ -46,12 +46,18 @@ export const eventsApi = {
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
},
rematch: async (marathonId: number, assignmentId: number): Promise<void> => {
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`)
// Game Choice event
getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise<GameChoiceChallenges> => {
const response = await client.get<GameChoiceChallenges>(`/marathons/${marathonId}/game-choice/challenges`, {
params: { game_id: gameId },
})
return response.data
},
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => {
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`)
selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, {
challenge_id: challengeId,
})
return response.data
},
@@ -64,4 +70,27 @@ export const eventsApi = {
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
return response.data
},
// Event Assignment (Common Enemy)
getEventAssignment: async (marathonId: number): Promise<EventAssignment> => {
const response = await client.get<EventAssignment>(`/marathons/${marathonId}/event-assignment`)
return response.data
},
completeEventAssignment: async (
assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File }
): Promise<CompleteResult> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment)
if (data.proof_file) formData.append('proof_file', data.proof_file)
const response = await client.post<CompleteResult>(
`/event-assignments/${assignmentId}/complete`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
}

View File

@@ -6,3 +6,4 @@ export { feedApi } from './feed'
export { adminApi } from './admin'
export { eventsApi } from './events'
export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'

View File

@@ -0,0 +1,22 @@
import client from './client'
export interface TelegramLinkToken {
token: string
bot_url: string
}
export interface TelegramStatus {
telegram_id: number | null
telegram_username: string | null
}
export const telegramApi = {
generateLinkToken: async (): Promise<TelegramLinkToken> => {
const response = await client.post<TelegramLinkToken>('/telegram/generate-link-token')
return response.data
},
unlinkTelegram: async (): Promise<void> => {
await client.post('/users/me/telegram/unlink')
},
}

View File

@@ -0,0 +1,282 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react'
import {
formatRelativeTime,
getActivityIcon,
getActivityColor,
getActivityBgClass,
isEventActivity,
formatActivityMessage,
} from '@/utils/activity'
interface ActivityFeedProps {
marathonId: number
className?: string
}
export interface ActivityFeedRef {
refresh: () => void
}
const ITEMS_PER_PAGE = 20
const POLL_INTERVAL = 10000 // 10 seconds
// Важные типы активности для отображения
const IMPORTANT_ACTIVITY_TYPES: ActivityType[] = [
'spin',
'complete',
'drop',
'start_marathon',
'finish_marathon',
'event_start',
'event_end',
'swap',
'rematch',
]
export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
({ marathonId, className = '' }, ref) => {
const [activities, setActivities] = useState<Activity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const lastFetchRef = useRef<number>(0)
const loadActivities = useCallback(async (offset = 0, append = false) => {
try {
const response = await feedApi.get(marathonId, ITEMS_PER_PAGE * 2, offset)
// Фильтруем только важные события
const filtered = response.items.filter(item =>
IMPORTANT_ACTIVITY_TYPES.includes(item.type)
)
if (append) {
setActivities(prev => [...prev, ...filtered])
} else {
setActivities(filtered)
}
setHasMore(response.has_more)
setTotal(filtered.length)
lastFetchRef.current = Date.now()
} catch (error) {
console.error('Failed to load activity feed:', error)
}
}, [marathonId])
// Expose refresh method
useImperativeHandle(ref, () => ({
refresh: () => loadActivities()
}), [loadActivities])
// Initial load
useEffect(() => {
setIsLoading(true)
loadActivities().finally(() => setIsLoading(false))
}, [loadActivities])
// Polling for new activities
useEffect(() => {
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
loadActivities()
}
}, POLL_INTERVAL)
return () => clearInterval(interval)
}, [loadActivities])
const handleLoadMore = async () => {
setIsLoadingMore(true)
await loadActivities(activities.length, true)
setIsLoadingMore(false)
}
if (isLoading) {
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
<div className="flex items-center gap-2 mb-4">
<Bell className="w-5 h-5 text-primary-500" />
<h3 className="font-medium text-white">Активность</h3>
</div>
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
</div>
)
}
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-primary-500" />
<h3 className="font-medium text-white">Активность</h3>
</div>
{total > 0 && (
<span className="text-xs text-gray-500">{total}</span>
)}
</div>
{/* Activity list */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{activities.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500 text-sm">
Пока нет активности
</div>
) : (
<div className="divide-y divide-gray-700/30">
{activities.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
)}
{/* Load more button */}
{hasMore && (
<div className="p-3 border-t border-gray-700/30">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2"
>
{isLoadingMore ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ChevronDown className="w-4 h-4" />
Загрузить ещё
</>
)}
</button>
</div>
)}
</div>
</div>
)
}
)
ActivityFeed.displayName = 'ActivityFeed'
interface ActivityItemProps {
activity: Activity
}
function ActivityItem({ activity }: ActivityItemProps) {
const navigate = useNavigate()
const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type)
const bgClass = getActivityBgClass(activity.type)
const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id
: null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status
: null
if (isEvent) {
return (
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${iconColor}`} />
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
{title}
</span>
</div>
{details && (
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}>
{details}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
{formatRelativeTime(activity.created_at)}
</div>
</div>
)
}
return (
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="flex-shrink-0">
{activity.user.avatar_url ? (
<img
src={activity.user.avatar_url}
alt={activity.user.nickname}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<span className="text-xs text-gray-400 font-medium">
{activity.user.nickname.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">
{activity.user.nickname}
</span>
<span className="text-xs text-gray-500">
{formatRelativeTime(activity.created_at)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
<span className="text-sm text-gray-300">{title}</span>
</div>
{details && (
<div className="text-sm text-gray-400 mt-1">
{details}
</div>
)}
{extra && (
<div className="text-xs text-gray-500 mt-0.5">
{extra}
</div>
)}
{/* Details button and dispute indicator for complete activities */}
{assignmentId && (
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Детали
</button>
{disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Оспаривается
</span>
)}
{disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Отклонено
</span>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react'
import type { ActiveEvent, EventType } from '@/types'
import { EVENT_INFO } from '@/types'
@@ -14,7 +14,7 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-5 h-5" />,
rematch: <RotateCcw className="w-5 h-5" />,
game_choice: <Gamepad2 className="w-5 h-5" />,
}
const EVENT_COLORS: Record<EventType, string> = {
@@ -23,7 +23,7 @@ const EVENT_COLORS: Record<EventType, string> = {
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
rematch: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
}
function formatTime(seconds: number): string {

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
import { Button } from '@/components/ui'
import { eventsApi } from '@/api'
import type { ActiveEvent, EventType, Challenge } from '@/types'
import { EVENT_INFO } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
interface EventControlProps {
marathonId: number
@@ -17,7 +19,7 @@ const EVENT_TYPES: EventType[] = [
'double_risk',
'jackpot',
'swap',
'rematch',
'game_choice',
'common_enemy',
]
@@ -27,7 +29,17 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
double_risk: <Shield className="w-4 h-4" />,
jackpot: <Gift className="w-4 h-4" />,
swap: <ArrowLeftRight className="w-4 h-4" />,
rematch: <RotateCcw className="w-4 h-4" />,
game_choice: <Gamepad2 className="w-4 h-4" />,
}
// Default durations for events (in minutes)
const DEFAULT_DURATIONS: Record<EventType, number | null> = {
golden_hour: 45,
common_enemy: null, // Until all complete
double_risk: 120,
jackpot: null, // 1 spin
swap: 60,
game_choice: 120,
}
export function EventControl({
@@ -36,14 +48,24 @@ export function EventControl({
challenges,
onEventChange,
}: EventControlProps) {
const toast = useToast()
const confirm = useConfirm()
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
const [durationMinutes, setDurationMinutes] = useState<number | ''>(45)
const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false)
// Update duration when event type changes
const handleTypeChange = (type: EventType) => {
setSelectedType(type)
const defaultDuration = DEFAULT_DURATIONS[type]
setDurationMinutes(defaultDuration ?? '')
}
const handleStart = async () => {
if (selectedType === 'common_enemy' && !selectedChallengeId) {
alert('Выберите челлендж для события "Общий враг"')
toast.warning('Выберите челлендж для события "Общий враг"')
return
}
@@ -51,19 +73,27 @@ export function EventControl({
try {
await eventsApi.start(marathonId, {
type: selectedType,
duration_minutes: durationMinutes || undefined,
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
})
onEventChange()
} catch (error) {
console.error('Failed to start event:', error)
alert('Не удалось запустить событие')
toast.error('Не удалось запустить событие')
} finally {
setIsStarting(false)
}
}
const handleStop = async () => {
if (!confirm('Остановить событие досрочно?')) return
const confirmed = await confirm({
title: 'Остановить событие?',
message: 'Событие будет завершено досрочно.',
confirmText: 'Остановить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsStopping(true)
try {
@@ -108,7 +138,7 @@ export function EventControl({
{EVENT_TYPES.map((type) => (
<button
key={type}
onClick={() => setSelectedType(type)}
onClick={() => handleTypeChange(type)}
className={`
p-3 rounded-lg border-2 transition-all text-left
${selectedType === type
@@ -127,6 +157,27 @@ export function EventControl({
))}
</div>
{/* Duration setting */}
{DEFAULT_DURATIONS[selectedType] !== null && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Длительность (минуты)
</label>
<input
type="number"
value={durationMinutes}
onChange={(e) => setDurationMinutes(e.target.value ? parseInt(e.target.value) : '')}
min={1}
max={480}
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
/>
<p className="text-xs text-gray-500 mt-1">
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
</p>
</div>
)}
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">

View File

@@ -0,0 +1,325 @@
import { useState, useEffect, useRef } from 'react'
import { MessageCircle, ExternalLink, X, Loader2, RefreshCw, CheckCircle, User, Link2, Link2Off } from 'lucide-react'
import { telegramApi } from '@/api/telegram'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/auth'
export function TelegramLink() {
const { user, updateUser } = useAuthStore()
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [botUrl, setBotUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isPolling, setIsPolling] = useState(false)
const [linkSuccess, setLinkSuccess] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isLinked = !!user?.telegram_id
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, [])
const startPolling = () => {
setIsPolling(true)
let attempts = 0
const maxAttempts = 60 // 5 minutes (5 sec intervals)
pollingRef.current = setInterval(async () => {
attempts++
try {
const userData = await authApi.me()
if (userData.telegram_id) {
// Success! User linked their account
updateUser({
telegram_id: userData.telegram_id,
telegram_username: userData.telegram_username,
telegram_first_name: userData.telegram_first_name,
telegram_last_name: userData.telegram_last_name,
telegram_avatar_url: userData.telegram_avatar_url
})
setLinkSuccess(true)
setIsPolling(false)
setBotUrl(null)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
} catch {
// Ignore errors, continue polling
}
if (attempts >= maxAttempts) {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, 5000)
}
const stopPolling = () => {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
const handleGenerateLink = async () => {
setLoading(true)
setError(null)
setLinkSuccess(false)
try {
const { bot_url } = await telegramApi.generateLinkToken()
setBotUrl(bot_url)
} catch {
setError('Не удалось сгенерировать ссылку')
} finally {
setLoading(false)
}
}
const handleUnlink = async () => {
setLoading(true)
setError(null)
try {
await telegramApi.unlinkTelegram()
updateUser({
telegram_id: null,
telegram_username: null,
telegram_first_name: null,
telegram_last_name: null,
telegram_avatar_url: null
})
setIsOpen(false)
} catch {
setError('Не удалось отвязать аккаунт')
} finally {
setLoading(false)
}
}
const handleOpenBot = () => {
if (botUrl) {
window.open(botUrl, '_blank')
startPolling()
}
}
const handleClose = () => {
setIsOpen(false)
setBotUrl(null)
setError(null)
setLinkSuccess(false)
stopPolling()
}
return (
<>
<button
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'
}`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
>
<MessageCircle className="w-5 h-5" />
</button>
{isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative">
<button
onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center">
<MessageCircle className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Telegram</h2>
<p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжи аккаунт'}
</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{isLinked || linkSuccess ? (
<div className="space-y-4">
{linkSuccess && (
<div className="p-4 bg-green-500/20 border border-green-500/50 rounded-lg flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-400 flex-shrink-0" />
<p className="text-green-400 font-medium">Аккаунт успешно привязан!</p>
</div>
)}
{/* User Profile Card */}
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50">
<div className="flex items-center gap-4">
{/* Avatar - prefer Telegram avatar */}
<div className="relative">
{user?.telegram_avatar_url || user?.avatar_url ? (
<img
src={user.telegram_avatar_url || user.avatar_url || ''}
alt={user.nickname}
className="w-16 h-16 rounded-full object-cover border-2 border-blue-500/50"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-8 h-8 text-white" />
</div>
)}
{/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800">
<Link2 className="w-3 h-3 text-white" />
</div>
</div>
{/* User Info */}
<div className="flex-1 min-w-0">
<p className="text-lg font-bold text-white truncate">
{[user?.telegram_first_name, user?.telegram_last_name].filter(Boolean).join(' ') || user?.nickname}
</p>
{user?.telegram_username && (
<p className="text-blue-400 font-medium truncate">@{user.telegram_username}</p>
)}
</div>
</div>
</div>
{/* Notifications Info */}
<div className="p-4 bg-gray-700/30 rounded-lg">
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-yellow-400">🌟</span>
<span>События (Golden Hour, Jackpot)</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-green-400">🚀</span>
<span>Старт и финиш марафонов</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-red-400"></span>
<span>Споры по заданиям</span>
</div>
</div>
</div>
<button
onClick={handleUnlink}
disabled={loading}
className="w-full py-3 px-4 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2 border border-red-500/30"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Link2Off className="w-4 h-4" />
Отвязать аккаунт
</>
)}
</button>
</div>
) : botUrl ? (
<div className="space-y-4">
{isPolling ? (
<>
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400 font-medium">Ожидание привязки...</p>
</div>
<p className="text-sm text-gray-400">
Открой бота в Telegram и нажми Start. Статус обновится автоматически.
</p>
</div>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram снова
</button>
</>
) : (
<>
<p className="text-gray-300">
Нажми кнопку ниже, чтобы открыть бота и завершить привязку:
</p>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram
</button>
<p className="text-sm text-gray-500 text-center">
Ссылка действительна 10 минут
</p>
</>
)}
</div>
) : (
<div className="space-y-4">
<p className="text-gray-300">
Привяжи Telegram, чтобы получать уведомления о важных событиях:
</p>
<ul className="text-sm text-gray-400 space-y-2">
<li className="flex items-center gap-2">
<span className="text-yellow-400">🌟</span>
Golden Hour - очки x1.5
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400">🎰</span>
Jackpot - очки x3
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400"></span>
Double Risk и другие события
</li>
</ul>
<button
onClick={handleGenerateLink}
disabled={loading}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<MessageCircle className="w-5 h-5" />
Привязать Telegram
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</>
)
}

View File

@@ -1,6 +1,7 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink'
export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore()
@@ -38,6 +39,8 @@ export function Layout() {
<span>{user?.nickname}</span>
</div>
<TelegramLink />
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors"

View File

@@ -0,0 +1,111 @@
import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { Button } from './Button'
const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />,
warning: <AlertTriangle className="w-6 h-6" />,
info: <Info className="w-6 h-6" />,
}
const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/20 text-red-500',
warning: 'bg-yellow-500/20 text-yellow-500',
info: 'bg-blue-500/20 text-blue-500',
}
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = {
danger: 'danger',
warning: 'primary',
info: 'primary',
}
export function ConfirmModal() {
const { isOpen, options, handleConfirm, handleCancel } = useConfirmStore()
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleCancel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, handleCancel])
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen || !options) return null
const variant = options.variant || 'warning'
const Icon = icons[variant]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
onClick={handleCancel}
/>
{/* Modal */}
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700">
{/* Close button */}
<button
onClick={handleCancel}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="p-6">
{/* Icon */}
<div className={clsx('w-12 h-12 rounded-full flex items-center justify-center mb-4', iconStyles[variant])}>
{Icon}
</div>
{/* Title */}
<h3 className="text-xl font-bold text-white mb-2">
{options.title}
</h3>
{/* Message */}
<p className="text-gray-400 mb-6 whitespace-pre-line">
{options.message}
</p>
{/* Actions */}
<div className="flex gap-3">
<Button
variant="secondary"
className="flex-1"
onClick={handleCancel}
>
{options.cancelText || 'Отмена'}
</Button>
<Button
variant={buttonVariants[variant]}
className="flex-1"
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
import { clsx } from 'clsx'
import { useToastStore, type Toast as ToastType } from '@/store/toast'
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
}
const styles = {
success: 'bg-green-500/20 border-green-500/50 text-green-400',
error: 'bg-red-500/20 border-red-500/50 text-red-400',
warning: 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400',
info: 'bg-blue-500/20 border-blue-500/50 text-blue-400',
}
const iconStyles = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
interface ToastItemProps {
toast: ToastType
onRemove: (id: string) => void
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const [isVisible, setIsVisible] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const Icon = icons[toast.type]
useEffect(() => {
// Trigger enter animation
requestAnimationFrame(() => setIsVisible(true))
}, [])
const handleRemove = () => {
setIsLeaving(true)
setTimeout(() => onRemove(toast.id), 200)
}
return (
<div
className={clsx(
'flex items-start gap-3 p-4 rounded-lg border backdrop-blur-sm shadow-lg',
'transition-all duration-200 ease-out',
styles[toast.type],
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
)}
>
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', iconStyles[toast.type])} />
<p className="flex-1 text-sm text-white">{toast.message}</p>
<button
onClick={handleRemove}
className="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
export function ToastContainer() {
const toasts = useToastStore((state) => state.toasts)
const removeToast = useToastStore((state) => state.removeToast)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onRemove={removeToast} />
</div>
))}
</div>
)
}

View File

@@ -1,3 +1,5 @@
export { Button } from './Button'
export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal'

View File

@@ -6,6 +6,30 @@ body {
@apply bg-gray-900 text-gray-100 min-h-screen;
}
/* Custom scrollbar styles */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Firefox */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4b5563 transparent;
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;

View File

@@ -0,0 +1,515 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { assignmentsApi } from '@/api'
import type { AssignmentDetail } from '@/types'
import { Card, CardContent, Button } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag
} from 'lucide-react'
export function AssignmentDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
// Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('')
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
// Comment
const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false)
// Voting
const [isVoting, setIsVoting] = useState(false)
useEffect(() => {
loadAssignment()
return () => {
// Cleanup blob URL on unmount
if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofMediaBlobUrl)
}
}
}, [id])
const loadAssignment = async () => {
if (!id) return
setIsLoading(true)
setError(null)
try {
const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data)
// Load proof media if exists
if (data.proof_image_url) {
try {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url)
setProofMediaType(type)
} catch {
// Ignore error, media just won't show
}
}
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
} finally {
setIsLoading(false)
}
}
const handleCreateDispute = async () => {
if (!id || !disputeReason.trim()) return
setIsCreatingDispute(true)
try {
await assignmentsApi.createDispute(parseInt(id), disputeReason)
setDisputeReason('')
setShowDisputeForm(false)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
} finally {
setIsCreatingDispute(false)
}
}
const handleVote = async (vote: boolean) => {
if (!assignment?.dispute) return
setIsVoting(true)
try {
await assignmentsApi.vote(assignment.dispute.id, vote)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
} finally {
setIsVoting(false)
}
}
const handleAddComment = async () => {
if (!assignment?.dispute || !commentText.trim()) return
setIsAddingComment(true)
try {
await assignmentsApi.addComment(assignment.dispute.id, commentText)
setCommentText('')
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий')
} finally {
setIsAddingComment(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getTimeRemaining = (expiresAt: string) => {
const now = new Date()
const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime()
if (diff <= 0) return 'Истекло'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
return `${hours}ч ${minutes}м`
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> Выполнено
</span>
)
case 'dropped':
return (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
<XCircle className="w-4 h-4" /> Пропущено
</span>
)
case 'returned':
return (
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Возвращено
</span>
)
default:
return (
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm">
Активно
</span>
)
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (error || !assignment) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p>
<Button onClick={() => navigate(-1)}>Назад</Button>
</div>
)
}
const dispute = assignment.dispute
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-6 h-6" />
</button>
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
</div>
{/* Challenge info */}
<Card className="mb-6">
<CardContent>
<div className="flex items-start justify-between mb-4">
<div>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
</div>
{getStatusBadge(assignment.status)}
</div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm">
+{assignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
{assignment.challenge.difficulty}
</span>
{assignment.challenge.estimated_time && (
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
~{assignment.challenge.estimated_time} мин
</span>
)}
</div>
<div className="text-sm text-gray-400 space-y-1">
<p>
<strong>Выполнил:</strong> {assignment.participant.nickname}
</p>
{assignment.completed_at && (
<p>
<strong>Дата:</strong> {formatDate(assignment.completed_at)}
</p>
)}
{assignment.points_earned > 0 && (
<p>
<strong>Получено очков:</strong> {assignment.points_earned}
</p>
)}
</div>
</CardContent>
</Card>
{/* Proof section */}
<Card className="mb-6">
<CardContent>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Image className="w-5 h-5" />
Доказательство
</h3>
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
<div className="mb-4">
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
<video
src={proofMediaBlobUrl}
controls
className="w-full rounded-lg max-h-96 bg-gray-900"
preload="metadata"
/>
) : (
<img
src={proofMediaBlobUrl}
alt="Proof"
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
/>
)
) : (
<div className="w-full h-48 bg-gray-900 rounded-lg flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
)}
</div>
)}
{/* Proof URL */}
{assignment.proof_url && (
<div className="mb-4">
<a
href={assignment.proof_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-400 hover:text-primary-300"
>
<ExternalLink className="w-4 h-4" />
{assignment.proof_url}
</a>
</div>
)}
{/* Proof comment */}
{assignment.proof_comment && (
<div className="p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
<p className="text-white">{assignment.proof_comment}</p>
</div>
)}
{!assignment.proof_image_url && !assignment.proof_url && (
<p className="text-gray-500 text-center py-4">Пруф не предоставлен</p>
)}
</CardContent>
</Card>
{/* Dispute button */}
{assignment.can_dispute && !dispute && !showDisputeForm && (
<Button
variant="danger"
className="w-full mb-6"
onClick={() => setShowDisputeForm(true)}
>
<Flag className="w-4 h-4 mr-2" />
Оспорить выполнение
</Button>
)}
{/* Dispute creation form */}
{showDisputeForm && !dispute && (
<Card className="mb-6 border-red-500/50">
<CardContent>
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Оспорить выполнение
</h3>
<p className="text-gray-400 text-sm mb-4">
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования.
</p>
<textarea
className="input w-full min-h-[100px] resize-none mb-4"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
/>
<div className="flex gap-3">
<Button
variant="danger"
className="flex-1"
onClick={handleCreateDispute}
isLoading={isCreatingDispute}
disabled={disputeReason.trim().length < 10}
>
Оспорить
</Button>
<Button
variant="secondary"
onClick={() => {
setShowDisputeForm(false)
setDisputeReason('')
}}
>
Отмена
</Button>
</div>
</CardContent>
</Card>
)}
{/* Dispute section */}
{dispute && (
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}>
<CardContent>
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Оспаривание
</h3>
{dispute.status === 'open' ? (
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
{getTimeRemaining(dispute.expires_at)}
</span>
) : dispute.status === 'valid' ? (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Пруф валиден
</span>
) : (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
<XCircle className="w-4 h-4" />
Пруф невалиден
</span>
)}
</div>
<div className="mb-4">
<p className="text-sm text-gray-400">
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
</p>
<p className="text-sm text-gray-400">
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
</p>
</div>
<div className="p-3 bg-gray-900 rounded-lg mb-4">
<p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white">{dispute.reason}</p>
</div>
{/* Voting section */}
{dispute.status === 'open' && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-2">
<ThumbsUp className="w-5 h-5 text-green-500" />
<span className="text-green-400 font-medium">{dispute.votes_valid}</span>
<span className="text-gray-500 text-sm">валидно</span>
</div>
<div className="flex items-center gap-2">
<ThumbsDown className="w-5 h-5 text-red-500" />
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span>
<span className="text-gray-500 text-sm">невалидно</span>
</div>
</div>
<div className="flex gap-3">
<Button
variant={dispute.my_vote === true ? 'primary' : 'secondary'}
className="flex-1"
onClick={() => handleVote(true)}
isLoading={isVoting}
disabled={isVoting}
>
<ThumbsUp className="w-4 h-4 mr-2" />
Валидно
</Button>
<Button
variant={dispute.my_vote === false ? 'danger' : 'secondary'}
className="flex-1"
onClick={() => handleVote(false)}
isLoading={isVoting}
disabled={isVoting}
>
<ThumbsDown className="w-4 h-4 mr-2" />
Невалидно
</Button>
</div>
{dispute.my_vote !== null && (
<p className="text-sm text-gray-500 mt-2 text-center">
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'}
</p>
)}
</div>
)}
{/* Comments section */}
<div>
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Обсуждение ({dispute.comments.length})
</h4>
{dispute.comments.length > 0 && (
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto">
{dispute.comments.map((comment) => (
<div key={comment.id} className="p-3 bg-gray-900 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${comment.user.id === user?.id ? 'text-primary-400' : 'text-white'}`}>
{comment.user.nickname}
{comment.user.id === user?.id && ' (Вы)'}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
<p className="text-gray-300 text-sm">{comment.text}</p>
</div>
))}
</div>
)}
{/* Add comment form */}
{dispute.status === 'open' && (
<div className="flex gap-2">
<input
type="text"
className="input flex-1"
placeholder="Написать комментарий..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
/>
<Button
onClick={handleAddComment}
isLoading={isAddingComment}
disabled={!commentText.trim()}
>
<Send className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -4,6 +4,8 @@ import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
@@ -13,6 +15,8 @@ export function LobbyPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [games, setGames] = useState<Game[]>([])
@@ -41,6 +45,20 @@ export function LobbyPage() {
const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({})
const [loadingChallenges, setLoadingChallenges] = useState<number | null>(null)
// Manual challenge creation
const [addingChallengeToGameId, setAddingChallengeToGameId] = useState<number | null>(null)
const [newChallenge, setNewChallenge] = useState({
title: '',
description: '',
type: 'completion',
difficulty: 'medium',
points: 50,
estimated_time: 30,
proof_type: 'screenshot',
proof_hint: '',
})
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
// Start marathon
const [isStarting, setIsStarting] = useState(false)
@@ -99,7 +117,14 @@ export function LobbyPage() {
}
const handleDeleteGame = async (gameId: number) => {
if (!confirm('Удалить эту игру?')) return
const confirmed = await confirm({
title: 'Удалить игру?',
message: 'Игра и все её челленджи будут удалены.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try {
await gamesApi.delete(gameId)
@@ -122,7 +147,14 @@ export function LobbyPage() {
}
const handleRejectGame = async (gameId: number) => {
if (!confirm('Отклонить эту игру?')) return
const confirmed = await confirm({
title: 'Отклонить игру?',
message: 'Игра будет удалена из списка ожидающих.',
confirmText: 'Отклонить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setModeratingGameId(gameId)
try {
@@ -143,6 +175,7 @@ export function LobbyPage() {
setExpandedGameId(gameId)
// Load challenges if we haven't loaded them yet
if (!gameChallenges[gameId]) {
setLoadingChallenges(gameId)
try {
@@ -150,14 +183,66 @@ export function LobbyPage() {
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (error) {
console.error('Failed to load challenges:', error)
// Set empty array to prevent repeated attempts
setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
} finally {
setLoadingChallenges(null)
}
}
}
const handleCreateChallenge = async (gameId: number) => {
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
toast.warning('Заполните название и описание')
return
}
setIsCreatingChallenge(true)
try {
await gamesApi.createChallenge(gameId, {
title: newChallenge.title.trim(),
description: newChallenge.description.trim(),
type: newChallenge.type,
difficulty: newChallenge.difficulty,
points: newChallenge.points,
estimated_time: newChallenge.estimated_time || undefined,
proof_type: newChallenge.proof_type,
proof_hint: newChallenge.proof_hint.trim() || undefined,
})
toast.success('Задание добавлено')
// Reset form
setNewChallenge({
title: '',
description: '',
type: 'completion',
difficulty: 'medium',
points: 50,
estimated_time: 30,
proof_type: 'screenshot',
proof_hint: '',
})
setAddingChallengeToGameId(null)
// Refresh challenges
const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось добавить задание')
} finally {
setIsCreatingChallenge(false)
}
}
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
if (!confirm('Удалить это задание?')) return
const confirmed = await confirm({
title: 'Удалить задание?',
message: 'Это действие нельзя отменить.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try {
await gamesApi.deleteChallenge(challengeId)
@@ -227,7 +312,16 @@ export function LobbyPage() {
}
const handleStartMarathon = async () => {
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
if (!id) return
const confirmed = await confirm({
title: 'Начать марафон?',
message: 'После старта нельзя будет добавить новые игры.',
confirmText: 'Начать',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsStarting(true)
try {
@@ -235,7 +329,7 @@ export function LobbyPage() {
navigate(`/marathons/${id}/play`)
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
toast.error(error.response?.data?.detail || 'Не удалось запустить марафон')
} finally {
setIsStarting(false)
}
@@ -286,12 +380,12 @@ export function LobbyPage() {
{/* Game header */}
<div
className={`flex items-center justify-between p-4 ${
game.challenges_count > 0 ? 'cursor-pointer hover:bg-gray-800/50' : ''
(game.status === 'approved') ? 'cursor-pointer hover:bg-gray-800/50' : ''
}`}
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{game.challenges_count > 0 && (
{game.status === 'approved' && (
<span className="text-gray-400 shrink-0">
{expandedGameId === game.id ? (
<ChevronUp className="w-4 h-4" />
@@ -364,7 +458,9 @@ export function LobbyPage() {
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : gameChallenges[game.id]?.length > 0 ? (
) : (
<>
{gameChallenges[game.id]?.length > 0 ? (
gameChallenges[game.id].map((challenge) => (
<div
key={challenge.id}
@@ -409,6 +505,132 @@ export function LobbyPage() {
Нет заданий
</p>
)}
{/* Add challenge form */}
{isOrganizer && game.status === 'approved' && (
addingChallengeToGameId === game.id ? (
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700">
<h4 className="font-medium text-white text-sm">Новое задание</h4>
<Input
placeholder="Название задания"
value={newChallenge.title}
onChange={(e) => setNewChallenge(prev => ({ ...prev, title: e.target.value }))}
/>
<textarea
placeholder="Описание (что нужно сделать)"
value={newChallenge.description}
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
<select
value={newChallenge.type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="completion">Прохождение</option>
<option value="no_death">Без смертей</option>
<option value="speedrun">Спидран</option>
<option value="collection">Коллекция</option>
<option value="achievement">Достижение</option>
<option value="challenge_run">Челлендж-ран</option>
</select>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
<select
value={newChallenge.difficulty}
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="easy">Легко (20-40 очков)</option>
<option value="medium">Средне (45-75 очков)</option>
<option value="hard">Сложно (90-150 очков)</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
<Input
type="number"
value={newChallenge.points}
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={500}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Время (мин)</label>
<Input
type="number"
value={newChallenge.estimated_time}
onChange={(e) => setNewChallenge(prev => ({ ...prev, estimated_time: parseInt(e.target.value) || 0 }))}
min={1}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label>
<select
value={newChallenge.proof_type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
<Input
placeholder="Что должно быть на пруфе"
value={newChallenge.proof_hint}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
>
<Plus className="w-4 h-4 mr-1" />
Добавить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAddingChallengeToGameId(null)}
>
Отмена
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
setAddingChallengeToGameId(game.id)
setExpandedGameId(game.id)
}}
className="w-full mt-2 border border-dashed border-gray-700 text-gray-400 hover:text-white hover:border-gray-600"
>
<Plus className="w-4 h-4 mr-1" />
Добавить задание вручную
</Button>
)
)}
</>
)}
</div>
)}
</div>

View File

@@ -1,18 +1,23 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react'
import { format } from 'date-fns'
export function MarathonPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [challenges, setChallenges] = useState<Challenge[]>([])
@@ -20,7 +25,9 @@ export function MarathonPage() {
const [copied, setCopied] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isJoining, setIsJoining] = useState(false)
const [isFinishing, setIsFinishing] = useState(false)
const [showEventControl, setShowEventControl] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null)
useEffect(() => {
loadMarathon()
@@ -60,6 +67,8 @@ export function MarathonPage() {
try {
const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData)
// Refresh activity feed when event changes
activityFeedRef.current?.refresh()
} catch (error) {
console.error('Failed to refresh event:', error)
}
@@ -79,7 +88,16 @@ export function MarathonPage() {
}
const handleDelete = async () => {
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return
if (!marathon) return
const confirmed = await confirm({
title: 'Удалить марафон?',
message: 'Все данные марафона будут удалены безвозвратно.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setIsDeleting(true)
try {
@@ -87,7 +105,7 @@ export function MarathonPage() {
navigate('/marathons')
} catch (error) {
console.error('Failed to delete marathon:', error)
alert('Не удалось удалить марафон')
toast.error('Не удалось удалить марафон')
} finally {
setIsDeleting(false)
}
@@ -102,12 +120,37 @@ export function MarathonPage() {
setMarathon(updated)
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось присоединиться')
toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
} finally {
setIsJoining(false)
}
}
const handleFinish = async () => {
if (!marathon) return
const confirmed = await confirm({
title: 'Завершить марафон?',
message: 'Марафон будет завершён досрочно. Участники больше не смогут выполнять задания.',
confirmText: 'Завершить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsFinishing(true)
try {
const updated = await marathonsApi.finish(marathon.id)
setMarathon(updated)
toast.success('Марафон завершён')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось завершить марафон')
} finally {
setIsFinishing(false)
}
}
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
@@ -122,13 +165,16 @@ export function MarathonPage() {
const canDelete = isCreator || user?.role === 'admin'
return (
<div className="max-w-4xl mx-auto">
<div className="max-w-7xl mx-auto">
{/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
К списку марафонов
</Link>
<div className="flex flex-col lg:flex-row gap-6">
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
@@ -151,7 +197,7 @@ export function MarathonPage() {
)}
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap justify-end">
{/* Кнопка присоединиться для открытых марафонов */}
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<Button onClick={handleJoinPublic} isLoading={isJoining}>
@@ -196,6 +242,18 @@ export function MarathonPage() {
</Button>
</Link>
{marathon.status === 'active' && isOrganizer && (
<Button
variant="secondary"
onClick={handleFinish}
isLoading={isFinishing}
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20"
>
<Flag className="w-4 h-4 mr-2" />
Завершить
</Button>
)}
{canDelete && (
<Button
variant="ghost"
@@ -210,7 +268,7 @@ export function MarathonPage() {
</div>
{/* Stats */}
<div className="grid md:grid-cols-5 gap-4 mb-8">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
@@ -360,5 +418,20 @@ export function MarathonPage() {
</Card>
)}
</div>
{/* Activity Feed - right sidebar */}
{isParticipant && (
<div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-4">
<ActivityFeed
ref={activityFeedRef}
marathonId={marathon.id}
className="lg:max-h-[calc(100vh-8rem)]"
/>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,18 +1,27 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
// File size limits
const MAX_IMAGE_SIZE = 15 * 1024 * 1024 // 15 MB
const MAX_VIDEO_SIZE = 30 * 1024 * 1024 // 30 MB
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov']
export function PlayPage() {
const { id } = useParams<{ id: string }>()
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [games, setGames] = useState<Game[]>([])
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -26,10 +35,11 @@ export function PlayPage() {
// Drop state
const [isDropping, setIsDropping] = useState(false)
// Rematch state
const [droppedAssignments, setDroppedAssignments] = useState<DroppedAssignment[]>([])
const [isRematchLoading, setIsRematchLoading] = useState(false)
const [rematchingId, setRematchingId] = useState<number | null>(null)
// Game Choice state
const [selectedGameId, setSelectedGameId] = useState<number | null>(null)
const [gameChoiceChallenges, setGameChoiceChallenges] = useState<GameChoiceChallenges | null>(null)
const [isLoadingChallenges, setIsLoadingChallenges] = useState(false)
const [isSelectingChallenge, setIsSelectingChallenge] = useState(false)
// Swap state
const [swapCandidates, setSwapCandidates] = useState<SwapCandidate[]>([])
@@ -41,18 +51,34 @@ export function PlayPage() {
// Common Enemy leaderboard state
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
// Tab state for Common Enemy
type PlayTab = 'spin' | 'event'
const [activeTab, setActiveTab] = useState<PlayTab>('spin')
// Event assignment state (Common Enemy)
const [eventAssignment, setEventAssignment] = useState<EventAssignment | null>(null)
const [eventProofFile, setEventProofFile] = useState<File | null>(null)
const [eventProofUrl, setEventProofUrl] = useState('')
const [eventComment, setEventComment] = useState('')
const [isEventCompleting, setIsEventCompleting] = useState(false)
// Returned assignments state
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadData()
}, [id])
// Load dropped assignments when rematch event is active
// Reset game choice state when event changes or ends
useEffect(() => {
if (activeEvent?.event?.type === 'rematch' && !currentAssignment) {
loadDroppedAssignments()
if (activeEvent?.event?.type !== 'game_choice') {
setSelectedGameId(null)
setGameChoiceChallenges(null)
}
}, [activeEvent?.event?.type, currentAssignment])
}, [activeEvent?.event?.type])
// Load swap candidates and requests when swap event is active
useEffect(() => {
@@ -74,16 +100,17 @@ export function PlayPage() {
}
}, [activeEvent?.event?.type])
const loadDroppedAssignments = async () => {
const loadGameChoiceChallenges = async (gameId: number) => {
if (!id) return
setIsRematchLoading(true)
setIsLoadingChallenges(true)
try {
const dropped = await eventsApi.getDroppedAssignments(parseInt(id))
setDroppedAssignments(dropped)
const challenges = await eventsApi.getGameChoiceChallenges(parseInt(id), gameId)
setGameChoiceChallenges(challenges)
} catch (error) {
console.error('Failed to load dropped assignments:', error)
console.error('Failed to load game choice challenges:', error)
toast.error('Не удалось загрузить челленджи для этой игры')
} finally {
setIsRematchLoading(false)
setIsLoadingChallenges(false)
}
}
@@ -120,19 +147,55 @@ export function PlayPage() {
}
}
const validateAndSetFile = (
file: File | null,
setFile: (file: File | null) => void,
inputRef: React.RefObject<HTMLInputElement>
) => {
if (!file) {
setFile(null)
return
}
const ext = file.name.split('.').pop()?.toLowerCase() || ''
const isImage = IMAGE_EXTENSIONS.includes(ext)
const isVideo = VIDEO_EXTENSIONS.includes(ext)
if (!isImage && !isVideo) {
toast.error('Неподдерживаемый формат файла')
if (inputRef.current) inputRef.current.value = ''
return
}
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE
const maxSizeMB = isImage ? 15 : 30
if (file.size > maxSize) {
toast.error(`Файл слишком большой. Максимум ${maxSizeMB} МБ для ${isImage ? 'изображений' : 'видео'}`)
if (inputRef.current) inputRef.current.value = ''
return
}
setFile(file)
}
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(gamesData)
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
@@ -155,11 +218,10 @@ export function PlayPage() {
try {
const result = await wheelApi.spin(parseInt(id))
setSpinResult(result)
return result.game
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось крутить')
toast.error(error.response?.data?.detail || 'Не удалось крутить')
return null
}
}
@@ -174,7 +236,7 @@ export function PlayPage() {
const handleComplete = async () => {
if (!currentAssignment) return
if (!proofFile && !proofUrl) {
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
@@ -186,18 +248,17 @@ export function PlayPage() {
comment: comment || undefined,
})
alert(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
// Reset form
setProofFile(null)
setProofUrl('')
setComment('')
setSpinResult(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить')
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setIsCompleting(false)
}
@@ -206,56 +267,117 @@ export function PlayPage() {
const handleDrop = async () => {
if (!currentAssignment) return
const penalty = spinResult?.drop_penalty || 0
if (!confirm(`Пропустить это задание? Вы потеряете ${penalty} очков.`)) return
const penalty = currentAssignment.drop_penalty
const confirmed = await confirm({
title: 'Пропустить задание?',
message: `Вы потеряете ${penalty} очков.`,
confirmText: 'Пропустить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsDropping(true)
try {
const result = await wheelApi.drop(currentAssignment.id)
alert(`Пропущено. Штраф: -${result.penalty} очков`)
toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
setSpinResult(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось пропустить')
toast.error(error.response?.data?.detail || 'Не удалось пропустить')
} finally {
setIsDropping(false)
}
}
const handleRematch = async (assignmentId: number) => {
if (!id) return
const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
setRematchingId(assignmentId)
setIsEventCompleting(true)
try {
await eventsApi.rematch(parseInt(id), assignmentId)
alert('Реванш начат! Выполните задание за 50% очков.')
const result = await eventsApi.completeEventAssignment(eventAssignment.assignment.id, {
proof_file: eventProofFile || undefined,
proof_url: eventProofUrl || undefined,
comment: eventComment || undefined,
})
toast.success(`Выполнено! +${result.points_earned} очков`)
// Reset form
setEventProofFile(null)
setEventProofUrl('')
setEventComment('')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось начать реванш')
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setRematchingId(null)
setIsEventCompleting(false)
}
}
const handleGameSelect = async (gameId: number) => {
setSelectedGameId(gameId)
await loadGameChoiceChallenges(gameId)
}
const handleChallengeSelect = async (challengeId: number) => {
if (!id) return
const hasActiveAssignment = !!currentAssignment
const confirmed = await confirm({
title: 'Выбрать челлендж?',
message: hasActiveAssignment
? 'Текущее задание будет заменено без штрафа.'
: 'Вы уверены, что хотите выбрать этот челлендж?',
confirmText: 'Выбрать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsSelectingChallenge(true)
try {
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
toast.success(result.message)
setSelectedGameId(null)
setGameChoiceChallenges(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выбрать челлендж')
} finally {
setIsSelectingChallenge(false)
}
}
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
if (!id) return
if (!confirm(`Отправить запрос на обмен с ${participantName}?\n\nВы предлагаете обменяться на: "${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`)) return
const confirmed = await confirm({
title: 'Отправить запрос на обмен?',
message: `Вы предлагаете обменяться с ${participantName} на:\n"${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`,
confirmText: 'Отправить',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setSendingRequestTo(participantId)
try {
await eventsApi.createSwapRequest(parseInt(id), participantId)
alert('Запрос на обмен отправлен! Ожидайте подтверждения.')
toast.success('Запрос на обмен отправлен! Ожидайте подтверждения.')
await loadSwapRequests()
await loadSwapCandidates()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отправить запрос')
toast.error(error.response?.data?.detail || 'Не удалось отправить запрос')
} finally {
setSendingRequestTo(null)
}
@@ -264,16 +386,23 @@ export function PlayPage() {
const handleAcceptSwapRequest = async (requestId: number) => {
if (!id) return
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return
const confirmed = await confirm({
title: 'Принять обмен?',
message: 'Задания будут обменяны сразу после подтверждения.',
confirmText: 'Принять',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setProcessingRequestId(requestId)
try {
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
alert('Обмен выполнен!')
toast.success('Обмен выполнен!')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить обмен')
toast.error(error.response?.data?.detail || 'Не удалось выполнить обмен')
} finally {
setProcessingRequestId(null)
}
@@ -288,7 +417,7 @@ export function PlayPage() {
await loadSwapRequests()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отклонить запрос')
toast.error(error.response?.data?.detail || 'Не удалось отклонить запрос')
} finally {
setProcessingRequestId(null)
}
@@ -304,7 +433,7 @@ export function PlayPage() {
await loadSwapCandidates()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отменить запрос')
toast.error(error.response?.data?.detail || 'Не удалось отменить запрос')
} finally {
setProcessingRequestId(null)
}
@@ -322,6 +451,42 @@ export function PlayPage() {
return <div>Марафон не найден</div>
}
// Check if marathon has ended by status or by date
const marathonEndDate = marathon.end_date ? new Date(marathon.end_date) : null
const isMarathonExpired = marathonEndDate && new Date() > marathonEndDate
const isMarathonEnded = marathon.status === 'finished' || isMarathonExpired
if (isMarathonEnded) {
return (
<div className="max-w-2xl mx-auto">
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
К марафону
</Link>
<Card>
<CardContent className="text-center py-12">
<div className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<Trophy className="w-8 h-8 text-yellow-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Марафон завершён</h2>
<p className="text-gray-400 mb-6">
{marathon.status === 'finished'
? 'Этот марафон был завершён организатором.'
: 'Этот марафон завершился по истечении срока.'}
</p>
<Link to={`/marathons/${id}/leaderboard`}>
<Button>
<Trophy className="w-4 h-4 mr-2" />
Посмотреть итоговый рейтинг
</Button>
</Link>
</CardContent>
</Card>
</div>
)
}
const participation = marathon.my_participation
return (
@@ -367,8 +532,292 @@ export function PlayPage() {
</div>
)}
{/* Common Enemy Leaderboard */}
{/* Returned assignments warning */}
{returnedAssignments.length > 0 && (
<Card className="mb-6 border-orange-500/50">
<CardContent>
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-orange-400">Возвращённые задания</h3>
<span className="ml-auto px-2 py-0.5 bg-orange-500/20 text-orange-400 text-sm rounded">
{returnedAssignments.length}
</span>
</div>
<p className="text-gray-400 text-sm mb-4">
Эти задания были оспорены. После текущего задания вам нужно будет их переделать.
</p>
<div className="space-y-2">
{returnedAssignments.map((ra) => (
<div
key={ra.id}
className="p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg"
>
<div className="flex items-start justify-between">
<div>
<p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
</div>
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
+{ra.challenge.points}
</span>
</div>
<p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">
<Button
variant={activeTab === 'spin' ? 'primary' : 'secondary'}
onClick={() => setActiveTab('spin')}
className="flex-1"
>
Мой прокрут
</Button>
<Button
variant={activeTab === 'event' ? 'primary' : 'secondary'}
onClick={() => setActiveTab('event')}
className="flex-1 relative"
>
Общий враг
{eventAssignment?.assignment && !eventAssignment.is_completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
)}
</Button>
</div>
)}
{/* Event tab content (Common Enemy) */}
{activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && (
<>
{/* Common Enemy Leaderboard */}
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-bold text-white">Выполнили челлендж</h3>
{commonEnemyLeaderboard.length > 0 && (
<span className="ml-auto text-gray-400 text-sm">
{commonEnemyLeaderboard.length} чел.
</span>
)}
</div>
{commonEnemyLeaderboard.length === 0 ? (
<div className="text-center py-4 text-gray-500">
Пока никто не выполнил. Будь первым!
</div>
) : (
<div className="space-y-2">
{commonEnemyLeaderboard.map((entry) => (
<div
key={entry.participant_id}
className={`
flex items-center gap-3 p-3 rounded-lg
${entry.rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
entry.rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
entry.rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
'bg-gray-800'}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${entry.rank === 1 ? 'bg-yellow-500 text-black' :
entry.rank === 2 ? 'bg-gray-400 text-black' :
entry.rank === 3 ? 'bg-orange-600 text-white' :
'bg-gray-700 text-gray-300'}
`}>
{entry.rank && entry.rank <= 3 ? (
<Trophy className="w-4 h-4" />
) : (
entry.rank
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">{entry.user.nickname}</p>
</div>
{entry.bonus_points > 0 && (
<span className="text-green-400 text-sm font-medium">
+{entry.bonus_points} бонус
</span>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Event Assignment Card */}
{eventAssignment?.assignment && !eventAssignment.is_completed ? (
<Card>
<CardContent>
<div className="text-center mb-6">
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm">
Задание события "Общий враг"
</span>
</div>
{/* Game */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Игра</h3>
<p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title}
</p>
</div>
{/* Challenge */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Задание</h3>
<p className="text-xl font-bold text-white mb-2">
{eventAssignment.assignment.challenge.title}
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge.description}
</p>
</div>
{/* Points */}
<div className="flex items-center gap-4 mb-6 text-sm">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full">
+{eventAssignment.assignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full">
{eventAssignment.assignment.challenge.difficulty}
</span>
{eventAssignment.assignment.challenge.estimated_time && (
<span className="text-gray-400">
~{eventAssignment.assignment.challenge.estimated_time} мин
</span>
)}
</div>
{/* Proof hint */}
{eventAssignment.assignment.challenge.proof_hint && (
<div className="mb-6 p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400">
<strong>Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
</p>
</div>
)}
{/* Proof upload */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
</label>
{/* File upload */}
<input
ref={eventFileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setEventProofFile, eventFileInputRef)}
/>
{eventProofFile ? (
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
<span className="text-white flex-1 truncate">{eventProofFile.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setEventProofFile(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div>
<Button
variant="secondary"
className="w-full"
onClick={() => eventFileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
</div>
<div className="text-center text-gray-500">или</div>
{/* URL input */}
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={eventProofUrl}
onChange={(e) => setEventProofUrl(e.target.value)}
/>
{/* Comment */}
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={eventComment}
onChange={(e) => setEventComment(e.target.value)}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
className="flex-1"
onClick={handleEventComplete}
isLoading={isEventCompleting}
disabled={!eventProofFile && !eventProofUrl}
>
Выполнено
</Button>
</div>
</CardContent>
</Card>
) : eventAssignment?.is_completed ? (
<Card>
<CardContent className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Задание выполнено!</h3>
<p className="text-gray-400">
Вы уже завершили челлендж события "Общий враг"
</p>
{eventAssignment.assignment && (
<p className="text-green-400 mt-2">
+{eventAssignment.assignment.points_earned} очков
</p>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="text-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-gray-500 mx-auto mb-4" />
<p className="text-gray-400">Загрузка задания события...</p>
</CardContent>
</Card>
)}
</>
)}
{/* Spin tab content - only show when spin tab is active or no common_enemy event */}
{(activeTab === 'spin' || activeEvent?.event?.type !== 'common_enemy') && (
<>
{/* Common Enemy Leaderboard - show on spin tab too for context */}
{activeEvent?.event?.type === 'common_enemy' && activeTab === 'spin' && commonEnemyLeaderboard.length > 0 && (
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
@@ -427,9 +876,106 @@ export function PlayPage() {
</Card>
)}
{/* Game Choice section - show ABOVE spin wheel during game_choice event (works with or without assignment) */}
{activeEvent?.event?.type === 'game_choice' && (
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<Gamepad2 className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-white">Выбор игры</h3>
</div>
<p className="text-gray-400 text-sm mb-4">
Выберите игру и один из 3 челленджей. {currentAssignment ? 'Текущее задание будет заменено без штрафа!' : ''}
</p>
{/* Game selection */}
{!selectedGameId && (
<div className="grid grid-cols-2 gap-2">
{games.map((game) => (
<button
key={game.id}
onClick={() => handleGameSelect(game.id)}
className="p-3 bg-gray-900 hover:bg-gray-800 rounded-lg text-left transition-colors"
>
<p className="text-white font-medium truncate">{game.title}</p>
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
</button>
))}
</div>
)}
{/* Challenge selection */}
{selectedGameId && (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="text-white font-medium">
{gameChoiceChallenges?.game_title || 'Загрузка...'}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedGameId(null)
setGameChoiceChallenges(null)
}}
>
<ArrowLeft className="w-4 h-4 mr-1" />
Назад
</Button>
</div>
{isLoadingChallenges ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
) : gameChoiceChallenges?.challenges.length ? (
<div className="space-y-3">
{gameChoiceChallenges.challenges.map((challenge) => (
<div
key={challenge.id}
className="p-4 bg-gray-900 rounded-lg"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-white font-medium">{challenge.title}</p>
<p className="text-gray-400 text-sm mt-1">{challenge.description}</p>
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 rounded">
+{challenge.points} очков
</span>
<span className="px-2 py-0.5 bg-gray-700 text-gray-300 rounded">
{challenge.difficulty}
</span>
{challenge.estimated_time && (
<span className="text-gray-500">~{challenge.estimated_time} мин</span>
)}
</div>
</div>
<Button
size="sm"
onClick={() => handleChallengeSelect(challenge.id)}
isLoading={isSelectingChallenge}
disabled={isSelectingChallenge}
>
Выбрать
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-gray-500 py-4">
Нет доступных челленджей для этой игры
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* No active assignment - show spin wheel */}
{!currentAssignment && (
<>
<Card>
<CardContent className="py-8">
<h2 className="text-2xl font-bold text-white mb-2 text-center">Крутите колесо!</h2>
@@ -443,56 +989,6 @@ export function PlayPage() {
/>
</CardContent>
</Card>
{/* Rematch section - show during rematch event */}
{activeEvent?.event?.type === 'rematch' && droppedAssignments.length > 0 && (
<Card className="mt-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<RotateCcw className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-white">Реванш</h3>
</div>
<p className="text-gray-400 text-sm mb-4">
Во время события "Реванш" вы можете повторить пропущенные задания за 50% очков
</p>
{isRematchLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
) : (
<div className="space-y-3">
{droppedAssignments.map((dropped) => (
<div
key={dropped.id}
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg"
>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{dropped.challenge.title}
</p>
<p className="text-gray-400 text-sm">
{dropped.challenge.game.title} {dropped.challenge.points * 0.5} очков
</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => handleRematch(dropped.id)}
isLoading={rematchingId === dropped.id}
disabled={rematchingId !== null}
>
<RotateCcw className="w-4 h-4 mr-1" />
Реванш
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</>
)}
{/* Active assignment */}
@@ -562,7 +1058,7 @@ export function PlayPage() {
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setProofFile(e.target.files?.[0] || null)}
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
/>
{proofFile ? (
@@ -577,6 +1073,7 @@ export function PlayPage() {
</Button>
</div>
) : (
<div>
<Button
variant="secondary"
className="w-full"
@@ -585,6 +1082,10 @@ export function PlayPage() {
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
</div>
@@ -623,7 +1124,7 @@ export function PlayPage() {
onClick={handleDrop}
isLoading={isDropping}
>
Пропустить (-{spinResult?.drop_penalty || 0})
Пропустить (-{currentAssignment.drop_penalty})
</Button>
</div>
</CardContent>
@@ -807,6 +1308,8 @@ export function PlayPage() {
)}
</>
)}
</>
)}
</div>
)
}

View File

@@ -17,6 +17,7 @@ interface AuthState {
clearError: () => void
setPendingInviteCode: (code: string | null) => void
consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void
}
export const useAuthStore = create<AuthState>()(
@@ -89,6 +90,13 @@ export const useAuthStore = create<AuthState>()(
set({ pendingInviteCode: null })
return code
},
updateUser: (updates) => {
const currentUser = get().user
if (currentUser) {
set({ user: { ...currentUser, ...updates } })
}
},
}),
{
name: 'auth-storage',

View File

@@ -0,0 +1,55 @@
import { create } from 'zustand'
export type ConfirmVariant = 'danger' | 'warning' | 'info'
interface ConfirmOptions {
title: string
message: string
confirmText?: string
cancelText?: string
variant?: ConfirmVariant
}
interface ConfirmState {
isOpen: boolean
options: ConfirmOptions | null
resolve: ((value: boolean) => void) | null
confirm: (options: ConfirmOptions) => Promise<boolean>
handleConfirm: () => void
handleCancel: () => void
}
export const useConfirmStore = create<ConfirmState>((set, get) => ({
isOpen: false,
options: null,
resolve: null,
confirm: (options) => {
return new Promise<boolean>((resolve) => {
set({
isOpen: true,
options,
resolve,
})
})
},
handleConfirm: () => {
const { resolve } = get()
if (resolve) resolve(true)
set({ isOpen: false, options: null, resolve: null })
},
handleCancel: () => {
const { resolve } = get()
if (resolve) resolve(false)
set({ isOpen: false, options: null, resolve: null })
},
}))
// Convenient hook
export const useConfirm = () => {
const confirm = useConfirmStore((state) => state.confirm)
return confirm
}

View File

@@ -0,0 +1,53 @@
import { create } from 'zustand'
export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface Toast {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastState {
toasts: Toast[]
addToast: (type: ToastType, message: string, duration?: number) => void
removeToast: (id: string) => void
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (type, message, duration = 4000) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
set((state) => ({
toasts: [...state.toasts, { id, type, message, duration }],
}))
if (duration > 0) {
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
}, duration)
}
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
},
}))
// Helper hooks for convenience
export const useToast = () => {
const addToast = useToastStore((state) => state.addToast)
return {
success: (message: string, duration?: number) => addToast('success', message, duration),
error: (message: string, duration?: number) => addToast('error', message, duration),
info: (message: string, duration?: number) => addToast('info', message, duration),
warning: (message: string, duration?: number) => addToast('warning', message, duration),
}
}

View File

@@ -7,6 +7,11 @@ export interface User {
nickname: string
avatar_url: string | null
role: UserRole
telegram_id: number | null
telegram_username: string | null
telegram_first_name: string | null
telegram_last_name: string | null
telegram_avatar_url: string | null
created_at: string
}
@@ -158,7 +163,7 @@ export interface ChallengesPreviewResponse {
}
// Assignment types
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
export interface Assignment {
id: number
@@ -170,6 +175,7 @@ export interface Assignment {
streak_at_completion: number | null
started_at: string
completed_at: string | null
drop_penalty: number
}
export interface SpinResult {
@@ -245,6 +251,14 @@ export interface CommonEnemyLeaderboardEntry {
bonus_points: number
}
// Event Assignment (Common Enemy)
export interface EventAssignment {
assignment: Assignment | null
event_id: number | null
challenge_id: number | null
is_completed: boolean
}
// Activity types
export type ActivityType =
| 'join'
@@ -256,6 +270,10 @@ export type ActivityType =
| 'add_game'
| 'approve_game'
| 'reject_game'
| 'event_start'
| 'event_end'
| 'swap'
| 'rematch'
export interface Activity {
id: number
@@ -278,7 +296,7 @@ export type EventType =
| 'double_risk'
| 'jackpot'
| 'swap'
| 'rematch'
| 'game_choice'
export interface MarathonEvent {
id: number
@@ -322,7 +340,7 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
color: 'red',
},
double_risk: {
name: 'Двойной риск',
name: 'Безопасная игра',
description: 'Дропы бесплатны, но очки x0.5',
color: 'purple',
},
@@ -336,13 +354,31 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
description: 'Можно поменяться заданием с другим участником',
color: 'blue',
},
rematch: {
name: 'Реванш',
description: 'Можно переделать проваленный челлендж за 50% очков',
game_choice: {
name: 'Выбор игры',
description: 'Выбери игру и один из 3 челленджей. Можно заменить задание без штрафа!',
color: 'orange',
},
}
// Game Choice types
export interface GameChoiceChallenge {
id: number
title: string
description: string
difficulty: Difficulty
points: number
estimated_time: number | null
proof_type: ProofType
proof_hint: string | null
}
export interface GameChoiceChallenges {
game_id: number
game_title: string
challenges: GameChoiceChallenge[]
}
// Admin types
export interface AdminUser {
id: number
@@ -374,3 +410,57 @@ export interface PlatformStats {
games_count: number
total_participations: number
}
// Dispute types
export type DisputeStatus = 'open' | 'valid' | 'invalid'
export interface DisputeComment {
id: number
user: User
text: string
created_at: string
}
export interface DisputeVote {
user: User
vote: boolean // true = valid, false = invalid
created_at: string
}
export interface Dispute {
id: number
raised_by: User
reason: string
status: DisputeStatus
comments: DisputeComment[]
votes: DisputeVote[]
votes_valid: number
votes_invalid: number
my_vote: boolean | null
expires_at: string
created_at: string
resolved_at: string | null
}
export interface AssignmentDetail {
id: number
challenge: Challenge
participant: User
status: AssignmentStatus
proof_url: string | null
proof_image_url: string | null
proof_comment: string | null
points_earned: number
streak_at_completion: number | null
started_at: string
completed_at: string | null
can_dispute: boolean
dispute: Dispute | null
}
export interface ReturnedAssignment {
id: number
challenge: Challenge
original_completed_at: string
dispute_reason: string
}

View File

@@ -0,0 +1,250 @@
import type { Activity, ActivityType, EventType } from '@/types'
import {
UserPlus,
RotateCcw,
CheckCircle,
XCircle,
Play,
Flag,
Plus,
ThumbsUp,
ThumbsDown,
Zap,
ZapOff,
ArrowLeftRight,
RefreshCw,
type LucideIcon,
} from 'lucide-react'
// Relative time formatting
export function formatRelativeTime(dateStr: string): string {
// Backend saves time in UTC, ensure we parse it correctly
// If the string doesn't end with 'Z', append it to indicate UTC
const utcDateStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'
const date = new Date(utcDateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return 'только что'
if (diffMin < 60) {
if (diffMin === 1) return '1 мин назад'
if (diffMin < 5) return `${diffMin} мин назад`
if (diffMin < 21 && diffMin > 4) return `${diffMin} мин назад`
return `${diffMin} мин назад`
}
if (diffHour < 24) {
if (diffHour === 1) return '1 час назад'
if (diffHour < 5) return `${diffHour} часа назад`
return `${diffHour} часов назад`
}
if (diffDay === 1) return 'вчера'
if (diffDay < 7) return `${diffDay} дн назад`
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
// Activity icon mapping
export function getActivityIcon(type: ActivityType): LucideIcon {
const icons: Record<ActivityType, LucideIcon> = {
join: UserPlus,
spin: RotateCcw,
complete: CheckCircle,
drop: XCircle,
start_marathon: Play,
finish_marathon: Flag,
add_game: Plus,
approve_game: ThumbsUp,
reject_game: ThumbsDown,
event_start: Zap,
event_end: ZapOff,
swap: ArrowLeftRight,
rematch: RefreshCw,
}
return icons[type] || Zap
}
// Activity color mapping
export function getActivityColor(type: ActivityType): string {
const colors: Record<ActivityType, string> = {
join: 'text-green-400',
spin: 'text-blue-400',
complete: 'text-green-400',
drop: 'text-red-400',
start_marathon: 'text-green-400',
finish_marathon: 'text-gray-400',
add_game: 'text-blue-400',
approve_game: 'text-green-400',
reject_game: 'text-red-400',
event_start: 'text-yellow-400',
event_end: 'text-gray-400',
swap: 'text-purple-400',
rematch: 'text-orange-400',
}
return colors[type] || 'text-gray-400'
}
// Activity background for special events
export function getActivityBgClass(type: ActivityType): string {
if (type === 'event_start') {
return 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border-yellow-700/50'
}
if (type === 'event_end') {
return 'bg-gray-800/50 border-gray-700/50'
}
return 'bg-gray-800/30 border-gray-700/30'
}
// Check if activity is a special event
export function isEventActivity(type: ActivityType): boolean {
return type === 'event_start' || type === 'event_end'
}
// Event type to Russian name mapping
const EVENT_NAMES: Record<EventType, string> = {
golden_hour: 'Золотой час',
common_enemy: 'Общий враг',
double_risk: 'Безопасная игра',
jackpot: 'Джекпот',
swap: 'Обмен',
game_choice: 'Выбор игры',
}
// Difficulty translation
const DIFFICULTY_NAMES: Record<string, string> = {
easy: 'Легкий',
medium: 'Средний',
hard: 'Сложный',
}
interface Winner {
nickname: string
rank: number
bonus_points: number
}
// Format activity message
export function formatActivityMessage(activity: Activity): { title: string; details?: string; extra?: string } {
const data = activity.data || {}
switch (activity.type) {
case 'join':
return { title: 'присоединился к марафону' }
case 'spin': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
const difficulty = data.difficulty ? DIFFICULTY_NAMES[data.difficulty as string] || '' : ''
return {
title: 'получил задание',
details: challenge || undefined,
extra: [game, difficulty].filter(Boolean).join(' • ') || undefined,
}
}
case 'complete': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
const points = data.points ? `+${data.points}` : ''
const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : ''
const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : ''
return {
title: `завершил ${points}`,
details: challenge || undefined,
extra: [game, streak, bonus].filter(Boolean).join(' • ') || undefined,
}
}
case 'drop': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
const penalty = data.penalty ? `-${data.penalty}` : ''
const free = data.free_drop ? '(бесплатно)' : ''
return {
title: `пропустил ${penalty} ${free}`.trim(),
details: challenge || undefined,
extra: game || undefined,
}
}
case 'start_marathon':
return { title: 'Марафон начался!' }
case 'finish_marathon':
return { title: 'Марафон завершён!' }
case 'add_game':
return {
title: 'добавил игру',
details: (data.game as string) || (data.game_title as string) || undefined,
}
case 'approve_game':
return {
title: 'одобрил игру',
details: (data.game as string) || (data.game_title as string) || undefined,
}
case 'reject_game':
return {
title: 'отклонил игру',
details: (data.game as string) || (data.game_title as string) || undefined,
}
case 'event_start': {
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
return {
title: 'СОБЫТИЕ НАЧАЛОСЬ',
details: eventName,
}
}
case 'event_end': {
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
const winners = data.winners as Winner[] | undefined
let winnersText = ''
if (winners && winners.length > 0) {
const medals = ['🥇', '🥈', '🥉']
winnersText = winners
.map((w) => `${medals[w.rank - 1] || ''} ${w.nickname} +${w.bonus_points}`)
.join(' ')
}
return {
title: 'Событие завершено',
details: eventName,
extra: winnersText || undefined,
}
}
case 'swap': {
const challenge = (data.challenge as string) || ''
const withUser = (data.with_user as string) || ''
return {
title: 'обменялся заданиями',
details: withUser ? `с ${withUser}` : undefined,
extra: challenge || undefined,
}
}
case 'rematch': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
return {
title: 'взял реванш',
details: challenge || undefined,
extra: game || undefined,
}
}
default:
return { title: 'выполнил действие' }
}
}