Clear project — starting fresh from spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dianaka123
2026-02-24 14:36:47 +03:00
parent 6fe452d4dc
commit 9eb68695e9
91 changed files with 310 additions and 13106 deletions

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(taskkill:*)",
"Bash(cmd.exe /c \"taskkill /PID 35364 /F\")",
"Bash(cmd.exe /c \"taskkill /PID 35364 /F 2>&1\")",
"Bash(echo:*)",
"Bash(cmd.exe /c \"ipconfig\")"
]
}
}

View File

@@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx expo:*)",
"Bash(pip install:*)",
"Bash(python -m pytest:*)",
"Bash(python:*)",
"Bash(/d/PoleDanceApp/backend/.venv/Scripts/pip install:*)"
]
}
}

View File

@@ -1,14 +0,0 @@
# PostgreSQL
POSTGRES_PASSWORD=changeme
# JWT — generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=changeme
# Instagram Graph API
# 1. Convert your Instagram account to Business/Creator and link it to a Facebook Page
# 2. Create a Facebook App at developers.facebook.com
# 3. Add Instagram Graph API product; grant instagram_basic + pages_read_engagement
# 4. Generate a long-lived User Access Token (valid 60 days; auto-refreshed by the app)
INSTAGRAM_USER_ID=123456789
INSTAGRAM_ACCESS_TOKEN=EAAxxxxxx...
INSTAGRAM_POLL_INTERVAL=1800 # seconds between Instagram polls (default 30 min)

47
.gitignore vendored
View File

@@ -1,47 +0,0 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/
*.db
*.db-shm
*.db-wal
# Alembic
backend/alembic/versions/__pycache__/
# Environment
.env
*.env.local
# Node / Expo
node_modules/
.expo/
dist/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
npm-debug.*
yarn-debug.*
yarn-error.*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log

View File

@@ -1,10 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,38 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,48 +0,0 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
import app.models # noqa: F401 — registers all models with Base.metadata
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = create_async_engine(settings.database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,195 +0,0 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-02-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("full_name", sa.String(255), nullable=False),
sa.Column("phone", sa.String(50), nullable=True),
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("expo_push_token", sa.String(512), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sa.String(255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
op.create_table(
"championships",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("location", sa.String(500), nullable=True),
sa.Column("event_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("registration_open_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("registration_close_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(30), nullable=False, server_default="draft"),
sa.Column("source", sa.String(20), nullable=False, server_default="manual"),
sa.Column("instagram_media_id", sa.String(100), nullable=True),
sa.Column("image_url", sa.String(1000), nullable=True),
sa.Column("raw_caption_text", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("instagram_media_id"),
)
op.create_table(
"registrations",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("championship_id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("category", sa.String(255), nullable=True),
sa.Column("level", sa.String(255), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="submitted"),
sa.Column(
"submitted_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["championship_id"], ["championships.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"championship_id", "user_id", name="uq_registration_champ_user"
),
)
op.create_index(
"idx_registrations_championship_id", "registrations", ["championship_id"]
)
op.create_index("idx_registrations_user_id", "registrations", ["user_id"])
op.create_table(
"participant_lists",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("championship_id", sa.Uuid(), nullable=False),
sa.Column("published_by", sa.Uuid(), nullable=False),
sa.Column("is_published", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["championship_id"], ["championships.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["published_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("championship_id"),
)
op.create_table(
"notification_log",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("registration_id", sa.Uuid(), nullable=True),
sa.Column("type", sa.String(50), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"sent_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("delivery_status", sa.String(30), server_default="pending"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["registration_id"], ["registrations.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_notification_log_user_id", "notification_log", ["user_id"])
def downgrade() -> None:
op.drop_table("notification_log")
op.drop_table("participant_lists")
op.drop_table("registrations")
op.drop_table("championships")
op.drop_table("refresh_tokens")
op.drop_table("users")

View File

@@ -1,22 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Database
database_url: str = "postgresql+asyncpg://poledance:poledance@localhost:5432/poledance"
# JWT
secret_key: str = "dev-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
# Instagram Graph API
instagram_user_id: str = ""
instagram_access_token: str = ""
instagram_poll_interval: int = 1800 # seconds
settings = Settings()

View File

@@ -1,64 +0,0 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.championship import Championship
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
async def get(db: AsyncSession, championship_id: str | uuid.UUID) -> Championship | None:
result = await db.execute(
select(Championship).where(Championship.id == _uuid(championship_id))
)
return result.scalar_one_or_none()
async def get_by_instagram_id(
db: AsyncSession, instagram_media_id: str
) -> Championship | None:
result = await db.execute(
select(Championship).where(
Championship.instagram_media_id == instagram_media_id
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession,
status: str | None = None,
skip: int = 0,
limit: int = 20,
) -> list[Championship]:
q = select(Championship)
if status:
q = q.where(Championship.status == status)
q = q.order_by(Championship.event_date.asc().nullslast()).offset(skip).limit(limit)
result = await db.execute(q)
return list(result.scalars().all())
async def create(db: AsyncSession, **kwargs) -> Championship:
champ = Championship(**kwargs)
db.add(champ)
await db.commit()
await db.refresh(champ)
return champ
async def update(db: AsyncSession, champ: Championship, **kwargs) -> Championship:
for key, value in kwargs.items():
if value is not None:
setattr(champ, key, value)
await db.commit()
await db.refresh(champ)
return champ
async def delete(db: AsyncSession, champ: Championship) -> None:
await db.delete(champ)
await db.commit()

View File

@@ -1,62 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.participant_list import ParticipantList
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
async def get_by_championship(
db: AsyncSession, championship_id: str | uuid.UUID
) -> ParticipantList | None:
result = await db.execute(
select(ParticipantList).where(
ParticipantList.championship_id == _uuid(championship_id)
)
)
return result.scalar_one_or_none()
async def upsert(
db: AsyncSession,
championship_id: uuid.UUID,
published_by: uuid.UUID,
notes: str | None,
) -> ParticipantList:
existing = await get_by_championship(db, championship_id)
if existing:
existing.notes = notes
existing.published_by = published_by
await db.commit()
await db.refresh(existing)
return existing
pl = ParticipantList(
championship_id=championship_id,
published_by=published_by,
notes=notes,
)
db.add(pl)
await db.commit()
await db.refresh(pl)
return pl
async def publish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
pl.is_published = True
pl.published_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(pl)
return pl
async def unpublish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
pl.is_published = False
pl.published_at = None
await db.commit()
await db.refresh(pl)
return pl

View File

@@ -1,43 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.refresh_token import RefreshToken
async def create(
db: AsyncSession,
user_id: uuid.UUID,
token_hash: str,
expires_at: datetime,
) -> RefreshToken:
rt = RefreshToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
db.add(rt)
await db.commit()
await db.refresh(rt)
return rt
async def get_by_hash(db: AsyncSession, token_hash: str) -> RefreshToken | None:
result = await db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
return result.scalar_one_or_none()
async def revoke(db: AsyncSession, rt: RefreshToken) -> None:
rt.revoked = True
await db.commit()
def is_valid(rt: RefreshToken) -> bool:
if rt.revoked:
return False
now = datetime.now(timezone.utc)
expires = rt.expires_at
# SQLite returns naive datetimes; normalise to UTC-aware for comparison
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
return expires > now

View File

@@ -1,86 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.registration import Registration
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
async def get(db: AsyncSession, registration_id: str | uuid.UUID) -> Registration | None:
result = await db.execute(
select(Registration).where(Registration.id == _uuid(registration_id))
)
return result.scalar_one_or_none()
async def get_by_champ_and_user(
db: AsyncSession, championship_id: str | uuid.UUID, user_id: str | uuid.UUID
) -> Registration | None:
result = await db.execute(
select(Registration).where(
Registration.championship_id == _uuid(championship_id),
Registration.user_id == _uuid(user_id),
)
)
return result.scalar_one_or_none()
async def list_by_user(db: AsyncSession, user_id: str | uuid.UUID) -> list[Registration]:
result = await db.execute(
select(Registration)
.where(Registration.user_id == _uuid(user_id))
.order_by(Registration.submitted_at.desc())
)
return list(result.scalars().all())
async def list_by_championship(
db: AsyncSession, championship_id: str | uuid.UUID
) -> list[Registration]:
result = await db.execute(
select(Registration)
.where(Registration.championship_id == _uuid(championship_id))
.order_by(Registration.submitted_at.asc())
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
championship_id: uuid.UUID,
user_id: uuid.UUID,
category: str | None,
level: str | None,
notes: str | None,
) -> Registration:
reg = Registration(
championship_id=championship_id,
user_id=user_id,
category=category,
level=level,
notes=notes,
)
db.add(reg)
await db.commit()
await db.refresh(reg)
return reg
async def update_status(
db: AsyncSession, reg: Registration, status: str
) -> Registration:
reg.status = status
reg.decided_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(reg)
return reg
async def delete(db: AsyncSession, reg: Registration) -> None:
await db.delete(reg)
await db.commit()

View File

@@ -1,72 +0,0 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services.auth_service import hash_password
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
uid = uuid.UUID(str(user_id)) if not isinstance(user_id, uuid.UUID) else user_id
result = await db.execute(select(User).where(User.id == uid))
return result.scalar_one_or_none()
async def create(
db: AsyncSession,
email: str,
password: str,
full_name: str,
phone: str | None = None,
role: str = "member",
status: str = "pending",
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
full_name=full_name,
phone=phone,
role=role,
status=status,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def list_all(
db: AsyncSession,
status: str | None = None,
role: str | None = None,
skip: int = 0,
limit: int = 50,
) -> list[User]:
q = select(User)
if status:
q = q.where(User.status == status)
if role:
q = q.where(User.role == role)
q = q.offset(skip).limit(limit)
result = await db.execute(q)
return list(result.scalars().all())
async def set_status(db: AsyncSession, user: User, status: str) -> User:
user.status = status
await db.commit()
await db.refresh(user)
return user
async def set_push_token(db: AsyncSession, user: User, token: str) -> User:
user.expo_push_token = token
await db.commit()
await db.refresh(user)
return user

View File

@@ -1,47 +0,0 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
pass
def _make_engine():
return create_async_engine(settings.database_url, echo=False)
def _make_session_factory(engine):
return async_sessionmaker(engine, expire_on_commit=False)
# Lazily initialised on first use so tests can patch settings before import
_engine = None
_session_factory = None
def get_engine():
global _engine
if _engine is None:
_engine = _make_engine()
return _engine
def get_session_factory():
global _session_factory
if _session_factory is None:
_session_factory = _make_session_factory(get_engine())
return _session_factory
# Alias kept for Alembic and bot usage
AsyncSessionLocal = None # populated on first call to get_session_factory()
async def get_db() -> AsyncGenerator[AsyncSession, None]:
factory = get_session_factory()
async with factory() as session:
yield session

View File

@@ -1,53 +0,0 @@
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.crud import crud_user
from app.database import AsyncSession, get_db
from app.models.user import User
from app.services.auth_service import decode_access_token
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
try:
payload = decode_access_token(credentials.credentials)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
)
user = await crud_user.get(db, payload["sub"])
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
async def get_approved_user(user: User = Depends(get_current_user)) -> User:
if user.status != "approved":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account pending approval",
)
return user
async def get_organizer(user: User = Depends(get_approved_user)) -> User:
if user.role not in ("organizer", "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Organizer access required",
)
return user
async def get_admin(user: User = Depends(get_approved_user)) -> User:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user

View File

@@ -1,56 +0,0 @@
from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, championships, participant_lists, registrations, users
from app.services.instagram_service import poll_instagram, refresh_instagram_token
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = AsyncIOScheduler()
scheduler.add_job(
poll_instagram,
"interval",
seconds=settings.instagram_poll_interval,
id="instagram_poll",
)
scheduler.add_job(
refresh_instagram_token,
"interval",
weeks=1,
id="instagram_token_refresh",
)
scheduler.start()
yield
scheduler.shutdown()
app = FastAPI(
title="Pole Dance Championships API",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten in Phase 7
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
PREFIX = "/api/v1"
app.include_router(auth.router, prefix=PREFIX)
app.include_router(users.router, prefix=PREFIX)
app.include_router(championships.router, prefix=PREFIX)
app.include_router(registrations.router, prefix=PREFIX)
app.include_router(participant_lists.router, prefix=PREFIX)
@app.get("/internal/health", tags=["internal"])
async def health():
return {"status": "ok"}

View File

@@ -1,15 +0,0 @@
from app.models.user import User
from app.models.refresh_token import RefreshToken
from app.models.championship import Championship
from app.models.registration import Registration
from app.models.participant_list import ParticipantList
from app.models.notification_log import NotificationLog
__all__ = [
"User",
"RefreshToken",
"Championship",
"Registration",
"ParticipantList",
"NotificationLog",
]

View File

@@ -1,41 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Championship(Base):
__tablename__ = "championships"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(String(500))
event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
registration_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
registration_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft")
# 'draft' | 'open' | 'closed' | 'completed'
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
# 'manual' | 'instagram'
instagram_media_id: Mapped[str | None] = mapped_column(String(100), unique=True)
image_url: Mapped[str | None] = mapped_column(String(1000))
raw_caption_text: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
registrations: Mapped[list["Registration"]] = relationship(
back_populates="championship", cascade="all, delete-orphan"
)
participant_list: Mapped["ParticipantList | None"] = relationship(
back_populates="championship", cascade="all, delete-orphan"
)

View File

@@ -1,34 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class NotificationLog(Base):
__tablename__ = "notification_log"
__table_args__ = (Index("idx_notification_log_user_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
registration_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("registrations.id", ondelete="SET NULL")
)
type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
delivery_status: Mapped[str] = mapped_column(String(30), default="pending")
user: Mapped["User"] = relationship(back_populates="notification_logs")
registration: Mapped["Registration | None"] = relationship(
back_populates="notification_logs"
)

View File

@@ -1,33 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class ParticipantList(Base):
__tablename__ = "participant_lists"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("championships.id", ondelete="CASCADE"), unique=True, nullable=False
)
published_by: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id"), nullable=False
)
is_published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
championship: Mapped["Championship"] = relationship(back_populates="participant_list")
organizer: Mapped["User"] = relationship()

View File

@@ -1,27 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
__table_args__ = (Index("idx_refresh_tokens_user_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped["User"] = relationship(back_populates="refresh_tokens")

View File

@@ -1,45 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Registration(Base):
__tablename__ = "registrations"
__table_args__ = (
UniqueConstraint("championship_id", "user_id", name="uq_registration_champ_user"),
Index("idx_registrations_championship_id", "championship_id"),
Index("idx_registrations_user_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("championships.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
category: Mapped[str | None] = mapped_column(String(255))
level: Mapped[str | None] = mapped_column(String(255))
notes: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="submitted")
# 'submitted' | 'accepted' | 'rejected' | 'waitlisted'
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
championship: Mapped["Championship"] = relationship(back_populates="registrations")
user: Mapped["User"] = relationship(back_populates="registrations")
notification_logs: Mapped[list["NotificationLog"]] = relationship(
back_populates="registration"
)

View File

@@ -1,40 +0,0 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
phone: Mapped[str | None] = mapped_column(String(50))
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
# 'member' | 'organizer' | 'admin'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
# 'pending' | 'approved' | 'rejected'
expo_push_token: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
registrations: Mapped[list["Registration"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
notification_logs: Mapped[list["NotificationLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)

View File

@@ -1,77 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_refresh_token, crud_user
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.schemas.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserOut
from app.services.auth_service import (
create_access_token,
create_refresh_token,
hash_token,
verify_password,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await crud_user.get_by_email(db, body.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
user = await crud_user.create(
db,
email=body.email,
password=body.password,
full_name=body.full_name,
phone=body.phone,
)
return await _issue_tokens(db, user)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
user = await crud_user.get_by_email(db, body.email)
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
)
return await _issue_tokens(db, user)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
hashed = hash_token(body.refresh_token)
rt = await crud_refresh_token.get_by_hash(db, hashed)
if not rt or not crud_refresh_token.is_valid(rt):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token"
)
await crud_refresh_token.revoke(db, rt)
user = await crud_user.get(db, rt.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return await _issue_tokens(db, user)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
hashed = hash_token(body.refresh_token)
rt = await crud_refresh_token.get_by_hash(db, hashed)
if rt:
await crud_refresh_token.revoke(db, rt)
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user
async def _issue_tokens(db: AsyncSession, user: User) -> TokenResponse:
access = create_access_token(str(user.id), user.role, user.status)
raw_rt, hashed_rt, expires_at = create_refresh_token()
await crud_refresh_token.create(db, user.id, hashed_rt, expires_at)
return TokenResponse(access_token=access, refresh_token=raw_rt)

View File

@@ -1,70 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_championship
from app.database import get_db
from app.dependencies import get_admin, get_approved_user, get_organizer
from app.models.user import User
from app.schemas.championship import ChampionshipCreate, ChampionshipOut, ChampionshipUpdate
router = APIRouter(prefix="/championships", tags=["championships"])
@router.get("", response_model=list[ChampionshipOut])
async def list_championships(
status: str | None = None,
skip: int = 0,
limit: int = 20,
_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
return await crud_championship.list_all(db, status=status, skip=skip, limit=limit)
@router.get("/{championship_id}", response_model=ChampionshipOut)
async def get_championship(
championship_id: str,
_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return champ
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
async def create_championship(
body: ChampionshipCreate,
_organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
return await crud_championship.create(db, **body.model_dump(), source="manual")
@router.patch("/{championship_id}", response_model=ChampionshipOut)
async def update_championship(
championship_id: str,
body: ChampionshipUpdate,
_organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = {k: v for k, v in body.model_dump().items() if v is not None}
return await crud_championship.update(db, champ, **updates)
@router.delete("/{championship_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_championship(
championship_id: str,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await crud_championship.delete(db, champ)

View File

@@ -1,75 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_championship, crud_participant
from app.database import get_db
from app.dependencies import get_approved_user, get_organizer
from app.models.user import User
from app.schemas.participant_list import ParticipantListOut, ParticipantListUpsert
from app.services import participant_service
router = APIRouter(prefix="/championships", tags=["participant-lists"])
@router.get("/{championship_id}/participant-list", response_model=ParticipantListOut)
async def get_participant_list(
championship_id: str,
_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
pl = await crud_participant.get_by_championship(db, championship_id)
if not pl or not pl.is_published:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Participant list not published yet",
)
return pl
@router.put("/{championship_id}/participant-list", response_model=ParticipantListOut)
async def upsert_participant_list(
championship_id: str,
body: ParticipantListUpsert,
organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return await crud_participant.upsert(
db,
championship_id=uuid.UUID(championship_id),
published_by=organizer.id,
notes=body.notes,
)
@router.post("/{championship_id}/participant-list/publish", response_model=ParticipantListOut)
async def publish_participant_list(
championship_id: str,
organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
try:
return await participant_service.publish_participant_list(
db, uuid.UUID(championship_id), organizer
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{championship_id}/participant-list/unpublish", response_model=ParticipantListOut)
async def unpublish_participant_list(
championship_id: str,
_organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
pl = await crud_participant.get_by_championship(db, championship_id)
if not pl:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return await crud_participant.unpublish(db, pl)

View File

@@ -1,123 +0,0 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_championship, crud_registration
from app.database import get_db
from app.dependencies import get_approved_user, get_organizer
from app.models.user import User
from app.schemas.registration import RegistrationCreate, RegistrationOut, RegistrationStatusUpdate
router = APIRouter(prefix="/registrations", tags=["registrations"])
@router.post("", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
async def submit_registration(
body: RegistrationCreate,
current_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, body.championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Championship not found")
if champ.status != "open":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration is not open"
)
if champ.registration_close_at and champ.registration_close_at < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration deadline has passed"
)
existing = await crud_registration.get_by_champ_and_user(
db, body.championship_id, current_user.id
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You have already registered for this championship",
)
return await crud_registration.create(
db,
championship_id=uuid.UUID(body.championship_id),
user_id=current_user.id,
category=body.category,
level=body.level,
notes=body.notes,
)
@router.get("/my", response_model=list[RegistrationOut])
async def my_registrations(
current_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
return await crud_registration.list_by_user(db, current_user.id)
@router.get("/{registration_id}", response_model=RegistrationOut)
async def get_registration(
registration_id: str,
current_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
reg = await crud_registration.get(db, registration_id)
if not reg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if str(reg.user_id) != str(current_user.id) and current_user.role not in (
"organizer",
"admin",
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return reg
@router.get("/championship/{championship_id}", response_model=list[RegistrationOut])
async def championship_registrations(
championship_id: str,
_organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, championship_id)
if not champ:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return await crud_registration.list_by_championship(db, championship_id)
@router.patch("/{registration_id}/status", response_model=RegistrationOut)
async def update_registration_status(
registration_id: str,
body: RegistrationStatusUpdate,
_organizer: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
allowed = {"accepted", "rejected", "waitlisted"}
if body.status not in allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Status must be one of: {', '.join(allowed)}",
)
reg = await crud_registration.get(db, registration_id)
if not reg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return await crud_registration.update_status(db, reg, body.status)
@router.delete("/{registration_id}", status_code=status.HTTP_204_NO_CONTENT)
async def withdraw_registration(
registration_id: str,
current_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
reg = await crud_registration.get(db, registration_id)
if not reg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if str(reg.user_id) != str(current_user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if reg.status != "submitted":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only submitted registrations can be withdrawn",
)
await crud_registration.delete(db, reg)

View File

@@ -1,101 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_user
from app.database import get_db
from app.dependencies import get_admin, get_current_user
from app.models.user import User
from app.schemas.user import PushTokenUpdate, UserCreate, UserOut
from app.services import notification_service
router = APIRouter(prefix="/users", tags=["users"])
@router.get("", response_model=list[UserOut])
async def list_users(
status: str | None = None,
role: str | None = None,
skip: int = 0,
limit: int = 50,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
return await crud_user.list_all(db, status=status, role=role, skip=skip, limit=limit)
@router.get("/{user_id}", response_model=UserOut)
async def get_user(
user_id: str,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
user = await crud_user.get(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return user
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
if await crud_user.get_by_email(db, body.email):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
return await crud_user.create(
db,
email=body.email,
password=body.password,
full_name=body.full_name,
phone=body.phone,
role=body.role,
status="approved",
)
@router.patch("/{user_id}/approve", response_model=UserOut)
async def approve_user(
user_id: str,
admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
user = await crud_user.get(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
user = await crud_user.set_status(db, user, "approved")
await notification_service.send_push_notification(
db=db,
user=user,
title="Welcome!",
body="Your account has been approved. You can now access the app.",
notif_type="account_approved",
)
return user
@router.patch("/{user_id}/reject", response_model=UserOut)
async def reject_user(
user_id: str,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
user = await crud_user.get(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return await crud_user.set_status(db, user, "rejected")
@router.patch("/{user_id}/push-token", status_code=status.HTTP_204_NO_CONTENT)
async def update_push_token(
user_id: str,
body: PushTokenUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if str(current_user.id) != user_id and current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
user = await crud_user.get(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await crud_user.set_push_token(db, user, body.expo_push_token)

View File

@@ -1,36 +0,0 @@
import uuid
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
model_config = {"from_attributes": True}

View File

@@ -1,43 +0,0 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class ChampionshipCreate(BaseModel):
title: str
description: str | None = None
location: str | None = None
event_date: datetime | None = None
registration_open_at: datetime | None = None
registration_close_at: datetime | None = None
status: str = "draft"
image_url: str | None = None
class ChampionshipUpdate(BaseModel):
title: str | None = None
description: str | None = None
location: str | None = None
event_date: datetime | None = None
registration_open_at: datetime | None = None
registration_close_at: datetime | None = None
status: str | None = None
image_url: str | None = None
class ChampionshipOut(BaseModel):
id: uuid.UUID
title: str
description: str | None
location: str | None
event_date: datetime | None
registration_open_at: datetime | None
registration_close_at: datetime | None
status: str
source: str
image_url: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -1,19 +0,0 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class ParticipantListUpsert(BaseModel):
notes: str | None = None
class ParticipantListOut(BaseModel):
id: uuid.UUID
championship_id: uuid.UUID
published_by: uuid.UUID
is_published: bool
published_at: datetime | None
notes: str | None
model_config = {"from_attributes": True}

View File

@@ -1,29 +0,0 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationStatusUpdate(BaseModel):
status: str # 'accepted' | 'rejected' | 'waitlisted'
class RegistrationOut(BaseModel):
id: uuid.UUID
championship_id: uuid.UUID
user_id: uuid.UUID
category: str | None
level: str | None
notes: str | None
status: str
submitted_at: datetime
decided_at: datetime | None
model_config = {"from_attributes": True}

View File

@@ -1,26 +0,0 @@
import uuid
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
role: str = "member"
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
model_config = {"from_attributes": True}
class PushTokenUpdate(BaseModel):
expo_push_token: str

View File

@@ -1,54 +0,0 @@
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: str, role: str, status: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {
"sub": user_id,
"role": role,
"status": status,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token() -> tuple[str, str, datetime]:
"""Returns (raw_token, hashed_token, expires_at)."""
raw = str(uuid.uuid4())
hashed = hashlib.sha256(raw.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(
days=settings.refresh_token_expire_days
)
return raw, hashed, expires_at
def decode_access_token(token: str) -> dict:
"""Raises jwt.InvalidTokenError on failure."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "access":
raise jwt.InvalidTokenError("Not an access token")
return payload
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()

View File

@@ -1,226 +0,0 @@
"""
Instagram Graph API polling service.
Setup requirements:
1. Convert organizer's Instagram to Business/Creator account and link to a Facebook Page.
2. Create a Facebook App at developers.facebook.com.
3. Add Instagram Graph API product with permissions: instagram_basic, pages_read_engagement.
4. Generate a long-lived User Access Token (valid 60 days) and set INSTAGRAM_ACCESS_TOKEN in .env.
5. Find your Instagram numeric user ID and set INSTAGRAM_USER_ID in .env.
The scheduler runs every INSTAGRAM_POLL_INTERVAL seconds (default: 1800 = 30 min).
Token is refreshed weekly to prevent expiry.
"""
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_session_factory
from app.models.championship import Championship
logger = logging.getLogger(__name__)
GRAPH_BASE = "https://graph.facebook.com/v21.0"
# Russian month names → month number
RU_MONTHS = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
LOCATION_PREFIXES = ["место:", "адрес:", "location:", "venue:", "зал:", "address:"]
DATE_PATTERNS = [
# 15 марта 2025
(
r"\b(\d{1,2})\s+("
+ "|".join(RU_MONTHS.keys())
+ r")\s+(\d{4})\b",
"ru",
),
# 15.03.2025
(r"\b(\d{1,2})\.(\d{2})\.(\d{4})\b", "dot"),
# March 15 2025 or March 15, 2025
(
r"\b(January|February|March|April|May|June|July|August|September|October|November|December)"
r"\s+(\d{1,2}),?\s+(\d{4})\b",
"en",
),
]
EN_MONTHS = {
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
@dataclass
class ParsedChampionship:
title: str
description: Optional[str]
location: Optional[str]
event_date: Optional[datetime]
raw_caption_text: str
image_url: Optional[str]
def parse_caption(text: str, image_url: str | None = None) -> ParsedChampionship:
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
title = lines[0] if lines else "Untitled Championship"
description = "\n".join(lines[1:]) if len(lines) > 1 else None
location = None
for line in lines:
lower = line.lower()
for prefix in LOCATION_PREFIXES:
if lower.startswith(prefix):
location = line[len(prefix):].strip()
break
event_date = _extract_date(text)
return ParsedChampionship(
title=title,
description=description,
location=location,
event_date=event_date,
raw_caption_text=text,
image_url=image_url,
)
def _extract_date(text: str) -> Optional[datetime]:
for pattern, fmt in DATE_PATTERNS:
m = re.search(pattern, text, re.IGNORECASE)
if not m:
continue
try:
if fmt == "ru":
day, month_name, year = int(m.group(1)), m.group(2).lower(), int(m.group(3))
month = RU_MONTHS.get(month_name)
if month:
return datetime(year, month, day, tzinfo=timezone.utc)
elif fmt == "dot":
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
return datetime(year, month, day, tzinfo=timezone.utc)
elif fmt == "en":
month_name, day, year = m.group(1).lower(), int(m.group(2)), int(m.group(3))
month = EN_MONTHS.get(month_name)
if month:
return datetime(year, month, day, tzinfo=timezone.utc)
except ValueError:
continue
return None
async def _upsert_championship(
session: AsyncSession,
instagram_media_id: str,
parsed: ParsedChampionship,
) -> Championship:
result = await session.execute(
select(Championship).where(
Championship.instagram_media_id == instagram_media_id
)
)
champ = result.scalar_one_or_none()
if champ:
champ.title = parsed.title
champ.description = parsed.description
champ.location = parsed.location
champ.event_date = parsed.event_date
champ.raw_caption_text = parsed.raw_caption_text
champ.image_url = parsed.image_url
else:
champ = Championship(
title=parsed.title,
description=parsed.description,
location=parsed.location,
event_date=parsed.event_date,
status="draft",
source="instagram",
instagram_media_id=instagram_media_id,
raw_caption_text=parsed.raw_caption_text,
image_url=parsed.image_url,
)
session.add(champ)
await session.commit()
return champ
async def poll_instagram() -> None:
"""Fetch recent posts from the monitored Instagram account and sync championships."""
if not settings.instagram_user_id or not settings.instagram_access_token:
logger.warning("Instagram credentials not configured — skipping poll")
return
url = (
f"{GRAPH_BASE}/{settings.instagram_user_id}/media"
f"?fields=id,caption,media_url,timestamp"
f"&access_token={settings.instagram_access_token}"
)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
except Exception as exc:
logger.error("Instagram API request failed: %s", exc)
return
posts = data.get("data", [])
logger.info("Instagram poll: fetched %d posts", len(posts))
async with get_session_factory()() as session:
for post in posts:
media_id = post.get("id")
caption = post.get("caption", "")
image_url = post.get("media_url")
if not caption:
continue
try:
parsed = parse_caption(caption, image_url)
await _upsert_championship(session, media_id, parsed)
logger.info("Synced championship from Instagram post %s: %s", media_id, parsed.title)
except Exception as exc:
logger.error("Failed to sync Instagram post %s: %s", media_id, exc)
async def refresh_instagram_token() -> None:
"""Refresh the long-lived Instagram token before it expires (run weekly)."""
if not settings.instagram_access_token:
return
url = (
f"{GRAPH_BASE}/oauth/access_token"
f"?grant_type=ig_refresh_token"
f"&access_token={settings.instagram_access_token}"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
new_token = response.json().get("access_token")
if new_token:
# In a production setup, persist the new token to the DB or secrets manager.
# For now, log it so it can be manually updated in .env.
logger.warning(
"Instagram token refreshed. Update INSTAGRAM_ACCESS_TOKEN in .env:\n%s",
new_token,
)
except Exception as exc:
logger.error("Failed to refresh Instagram token: %s", exc)

View File

@@ -1,44 +0,0 @@
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification_log import NotificationLog
from app.models.user import User
EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"
async def send_push_notification(
db: AsyncSession,
user: User,
title: str,
body: str,
notif_type: str,
registration_id: str | None = None,
) -> None:
delivery_status = "skipped"
if user.expo_push_token:
payload = {
"to": user.expo_push_token,
"title": title,
"body": body,
"data": {"type": notif_type, "registration_id": registration_id},
"sound": "default",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(EXPO_PUSH_URL, json=payload)
delivery_status = "sent" if response.status_code == 200 else "failed"
except Exception:
delivery_status = "failed"
log = NotificationLog(
user_id=user.id,
registration_id=registration_id,
type=notif_type,
title=title,
body=body,
delivery_status=delivery_status,
)
db.add(log)
await db.commit()

View File

@@ -1,49 +0,0 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_championship, crud_participant, crud_registration, crud_user
from app.models.participant_list import ParticipantList
from app.models.user import User
from app.services import notification_service
async def publish_participant_list(
db: AsyncSession,
championship_id: uuid.UUID,
organizer: User,
) -> ParticipantList:
pl = await crud_participant.get_by_championship(db, championship_id)
if not pl:
raise ValueError("Participant list not found — create it first")
pl = await crud_participant.publish(db, pl)
championship = await crud_championship.get(db, championship_id)
registrations = await crud_registration.list_by_championship(db, championship_id)
for reg in registrations:
user = await crud_user.get(db, reg.user_id)
if not user:
continue
if reg.status == "accepted":
title = "Congratulations!"
body = f"You've been accepted to {championship.title}!"
elif reg.status == "rejected":
title = "Application Update"
body = f"Unfortunately, your application to {championship.title} was not accepted this time."
else:
title = "Application Update"
body = f"You are on the waitlist for {championship.title}."
await notification_service.send_push_notification(
db=db,
user=user,
title=title,
body=body,
notif_type=reg.status,
registration_id=str(reg.id),
)
return pl

View File

@@ -1,2 +0,0 @@
[pytest]
asyncio_mode = auto

View File

@@ -1,18 +0,0 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic-settings==2.7.0
pydantic[email]==2.10.3
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
PyJWT==2.10.1
python-multipart==0.0.20
httpx==0.28.1
apscheduler==3.11.0
slowapi==0.1.9
pytest==8.3.4
pytest-asyncio==0.25.2
pytest-httpx==0.35.0
aiosqlite==0.20.0

View File

@@ -1,55 +0,0 @@
import asyncio
import os
# Override DATABASE_URL before any app code is imported so the lazy engine
# initialises with SQLite (no asyncpg required in the test environment).
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def db_engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(db_engine):
factory = async_sessionmaker(db_engine, expire_on_commit=False)
async with factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()

View File

@@ -1,89 +0,0 @@
import pytest
@pytest.mark.asyncio
async def test_register_and_login(client):
# Register
res = await client.post(
"/api/v1/auth/register",
json={
"email": "test@example.com",
"password": "secret123",
"full_name": "Test User",
},
)
assert res.status_code == 201
tokens = res.json()
assert "access_token" in tokens
assert "refresh_token" in tokens
# Duplicate registration should fail
res2 = await client.post(
"/api/v1/auth/register",
json={
"email": "test@example.com",
"password": "secret123",
"full_name": "Test User",
},
)
assert res2.status_code == 409
# Login with correct credentials
res3 = await client.post(
"/api/v1/auth/login",
json={"email": "test@example.com", "password": "secret123"},
)
assert res3.status_code == 200
# Login with wrong password
res4 = await client.post(
"/api/v1/auth/login",
json={"email": "test@example.com", "password": "wrong"},
)
assert res4.status_code == 401
@pytest.mark.asyncio
async def test_me_requires_auth(client):
res = await client.get("/api/v1/auth/me")
assert res.status_code in (401, 403) # missing Authorization header
@pytest.mark.asyncio
async def test_pending_user_cannot_access_championships(client):
await client.post(
"/api/v1/auth/register",
json={"email": "pending@example.com", "password": "pw", "full_name": "Pending"},
)
login = await client.post(
"/api/v1/auth/login",
json={"email": "pending@example.com", "password": "pw"},
)
token = login.json()["access_token"]
res = await client.get(
"/api/v1/championships",
headers={"Authorization": f"Bearer {token}"},
)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_token_refresh(client):
await client.post(
"/api/v1/auth/register",
json={"email": "refresh@example.com", "password": "pw", "full_name": "Refresh"},
)
login = await client.post(
"/api/v1/auth/login",
json={"email": "refresh@example.com", "password": "pw"},
)
refresh_token = login.json()["refresh_token"]
res = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert res.status_code == 200
new_tokens = res.json()
assert "access_token" in new_tokens
# Old refresh token should now be revoked
res2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert res2.status_code == 401

View File

@@ -1,46 +0,0 @@
from datetime import datetime, timezone
from app.services.instagram_service import parse_caption
def test_parse_basic_russian_post():
text = """Открытый Чемпионат по Pole Dance
Место: Москва, ул. Арбат, 10
Дата: 15 марта 2026
Регистрация открыта!"""
result = parse_caption(text)
assert result.title == "Открытый Чемпионат по Pole Dance"
assert result.location == "Москва, ул. Арбат, 10"
assert result.event_date == datetime(2026, 3, 15, tzinfo=timezone.utc)
def test_parse_dot_date_format():
text = "Summer Cup\nLocation: Saint Petersburg\n15.07.2026"
result = parse_caption(text)
assert result.event_date == datetime(2026, 7, 15, tzinfo=timezone.utc)
assert result.location == "Saint Petersburg"
def test_parse_english_date():
text = "Winter Championship\nVenue: Moscow Arena\nJanuary 20, 2027"
result = parse_caption(text)
assert result.event_date == datetime(2027, 1, 20, tzinfo=timezone.utc)
def test_parse_no_date_returns_none():
text = "Some announcement\nNo date here"
result = parse_caption(text)
assert result.event_date is None
assert result.title == "Some announcement"
def test_parse_with_image_url():
text = "Spring Cup"
result = parse_caption(text, image_url="https://example.com/img.jpg")
assert result.image_url == "https://example.com/img.jpg"
def test_parse_empty_caption():
result = parse_caption("")
assert result.title == "Untitled Championship"
assert result.description is None

310
dance-champ-app.md Normal file
View File

@@ -0,0 +1,310 @@
# Pole Dance Championships App — Technical Document
## 1. Project Overview
### Problem
Pole dance championships lack dedicated tools for organizers and participants. Organizers rely on Instagram posts to announce events, and registrations are managed via Google Forms — leading to:
- **No visibility** — Participants can't track their registration status in one place.
- **Scattered info** — Dates, rules, and results are buried across Instagram posts.
- **No access control** — Anyone can register; organizers need a way to accept or reject participants.
### Solution
A **members-only mobile app** for pole dance championship participants and organizers.
- New users register and wait for admin approval before accessing the app.
- Championship announcements are auto-imported from Instagram (Graph API).
- Members browse championships, submit registrations, and track their status.
- Organizers accept, reject, or waitlist participants and publish the final participant list.
- Push notifications alert members when results are published.
---
## 2. User Roles
| Role | Access |
|---|---|
| **Member** | Browse championships, register, track own registration status |
| **Organizer** | All of the above + manage registrations, publish participant lists |
| **Admin** | All of the above + approve/reject new member accounts |
New accounts start as `pending` and must be approved by an admin before they can use the app.
---
## 3. Tech Stack
| Layer | Technology |
|---|---|
| **Mobile** | React Native + TypeScript (Expo managed workflow) |
| **Navigation** | React Navigation v6 |
| **Server state** | TanStack React Query v5 |
| **Client state** | Zustand |
| **Backend** | FastAPI (Python, async) |
| **ORM** | SQLAlchemy 2.0 async |
| **Database** | SQLite (local dev) / PostgreSQL (production) |
| **Migrations** | Alembic |
| **Auth** | JWT — access tokens (15 min) + refresh tokens (7 days, rotation-based) |
| **Instagram sync** | Instagram Graph API, polled every 30 min via APScheduler |
| **Push notifications** | Expo Push API (routes to FCM + APNs) |
| **Token storage** | expo-secure-store (with in-memory cache for reliability) |
---
## 4. Architecture
```
┌─────────────────────┐ ┌──────────────────────────────┐
│ React Native App │◄──────►│ FastAPI Backend │
│ (Expo Go / APK) │ HTTPS │ /api/v1/... │
└─────────────────────┘ │ │
│ ┌──────────────────────┐ │
│ │ APScheduler │ │
│ │ - Instagram poll/30m│ │
│ │ - Token refresh/7d │ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ SQLite / PostgreSQL │ │
│ └──────────────────────┘ │
└──────────────────────────────┘
```
---
## 5. Data Model
### User
```
id UUID (PK)
email String (unique)
full_name String
phone String (nullable)
hashed_password String
role Enum: member | organizer | admin
status Enum: pending | approved | rejected
expo_push_token String (nullable)
created_at DateTime
updated_at DateTime
```
### Championship
```
id UUID (PK)
title String
description String (nullable)
location String (nullable)
event_date DateTime (nullable)
registration_open_at DateTime (nullable)
registration_close_at DateTime (nullable)
status Enum: draft | open | closed | completed
source Enum: manual | instagram
instagram_media_id String (nullable, unique)
image_url String (nullable)
raw_caption_text Text (nullable)
created_at DateTime
updated_at DateTime
```
### Registration
```
id UUID (PK)
championship_id UUID (FK → Championship)
user_id UUID (FK → User)
category String (nullable)
level String (nullable)
notes Text (nullable)
status Enum: submitted | accepted | rejected | waitlisted
submitted_at DateTime
decided_at DateTime (nullable)
```
### ParticipantList
```
id UUID (PK)
championship_id UUID (FK → Championship, unique)
published_by UUID (FK → User)
is_published Boolean
published_at DateTime (nullable)
notes Text (nullable)
```
### RefreshToken
```
id UUID (PK)
user_id UUID (FK → User)
token_hash String (SHA-256, unique)
expires_at DateTime
revoked Boolean
created_at DateTime
```
### NotificationLog
```
id UUID (PK)
user_id UUID (FK → User)
championship_id UUID (FK → Championship, nullable)
title String
body String
sent_at DateTime
status String
```
---
## 6. API Endpoints
### Auth
| Method | Path | Description |
|---|---|---|
| POST | `/api/v1/auth/register` | Register new account (status=pending) |
| POST | `/api/v1/auth/login` | Login, get access + refresh tokens |
| POST | `/api/v1/auth/refresh` | Refresh access token |
| POST | `/api/v1/auth/logout` | Revoke refresh token |
| GET | `/api/v1/auth/me` | Get current user |
### Championships
| Method | Path | Access |
|---|---|---|
| GET | `/api/v1/championships` | Approved members |
| GET | `/api/v1/championships/{id}` | Approved members |
| POST | `/api/v1/championships` | Organizer+ |
| PATCH | `/api/v1/championships/{id}` | Organizer+ |
| DELETE | `/api/v1/championships/{id}` | Admin |
### Registrations
| Method | Path | Access |
|---|---|---|
| POST | `/api/v1/championships/{id}/register` | Approved member |
| GET | `/api/v1/championships/{id}/registrations` | Organizer+ |
| PATCH | `/api/v1/registrations/{id}/status` | Organizer+ |
| GET | `/api/v1/registrations/mine` | Member (own only) |
| DELETE | `/api/v1/registrations/{id}` | Member (own, before decision) |
### Participant Lists
| Method | Path | Access |
|---|---|---|
| GET | `/api/v1/championships/{id}/participant-list` | Approved member |
| PUT | `/api/v1/championships/{id}/participant-list` | Organizer+ |
| POST | `/api/v1/championships/{id}/participant-list/publish` | Organizer+ |
| POST | `/api/v1/championships/{id}/participant-list/unpublish` | Organizer+ |
### Users (Admin)
| Method | Path | Description |
|---|---|---|
| GET | `/api/v1/users` | List all users |
| PATCH | `/api/v1/users/{id}/status` | Approve/reject member |
| PATCH | `/api/v1/users/me/push-token` | Register push token |
---
## 7. Mobile Screens
### Auth Flow
- **LoginScreen** — Email + password login
- **RegisterScreen** — Full name, phone, email, password
- **PendingApprovalScreen** — Shown after register until admin approves
### App (approved members)
- **ChampionshipListScreen** — All championships with status badges
- **ChampionshipDetailScreen** — Full info + registration button
- **RegistrationFormScreen** — Category, level, notes
- **ProfileScreen** — User info, logout
### Navigation
```
No user ──► AuthStack (Login / Register)
Pending ──► PendingApprovalScreen
Approved ──► AppStack (Championships / Profile)
```
---
## 8. Instagram Integration
### How It Works
1. Admin configures `INSTAGRAM_USER_ID` and `INSTAGRAM_ACCESS_TOKEN` in `.env`.
2. APScheduler polls the Instagram Graph API every 30 minutes.
3. New media posts are parsed: title (first line of caption), location (`Место:` / `Location:` prefix), dates (regex for RU + EN date formats).
4. New championships are created with `status=draft` for admin review.
5. Long-lived tokens are refreshed weekly automatically.
### Parsing Logic
- **Title** — First non-empty line of the caption.
- **Location** — Line starting with `Место:` or `Location:`.
- **Date** — Regex matches formats like `15 апреля 2026`, `April 15, 2026`, `15.04.2026`.
- **Deduplication** — Championships are matched by `instagram_media_id`.
---
## 9. Local Development Setup
### Prerequisites
- Python 3.11+
- Node.js 18+
- Expo Go app on phone
### Backend
```bat
cd backend
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
alembic upgrade head
python scripts/create_admin.py # creates admin@poledance.app / Admin1234
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
Or use: `start-backend.bat`
### Mobile
```bat
cd mobile
npm install
npx expo start --lan --clear
```
Or use: `start-mobile.bat`
### Environment (backend/.env)
```
DATABASE_URL=sqlite+aiosqlite:///./poledance.db
SECRET_KEY=dev-secret-key-change-before-production-32x
INSTAGRAM_USER_ID=
INSTAGRAM_ACCESS_TOKEN=
INSTAGRAM_POLL_INTERVAL=1800
```
### Environment (mobile/.env)
```
EXPO_PUBLIC_API_URL=http://<your-local-ip>:8000/api/v1
```
### Test Accounts
| Email | Password | Role | Status |
|---|---|---|---|
| admin@poledance.app | Admin1234 | admin | approved |
| anna@example.com | Member1234 | member | approved |
| maria@example.com | Member1234 | member | approved |
| elena@example.com | Member1234 | member | pending |
---
## 10. Known Limitations (Local Dev)
- **SQLite** is used instead of PostgreSQL — change `DATABASE_URL` for production.
- **Push notifications** don't work in Expo Go SDK 53 — requires a development build.
- **Instagram polling** requires a valid Business/Creator account token.
- Windows Firewall must allow inbound TCP on port 8000 for phone to reach the backend.
---
## 11. Future Features
- **Web admin panel** — Browser-based dashboard for organizers to manage registrations and championships.
- **In-app notifications feed** — History of received push notifications.
- **Calendar sync** — Export championship dates to phone calendar.
- **Production deployment** — Docker + PostgreSQL + nginx + SSL.
- **OCR / LLM parsing** — Better extraction of championship details from Instagram images and captions.
- **Multi-organizer** — Support multiple Instagram accounts for different championships.

View File

@@ -1,41 +0,0 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: poledance
POSTGRES_USER: poledance
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U poledance"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
environment:
DATABASE_URL: postgresql+asyncpg://poledance:${POSTGRES_PASSWORD}@postgres:5432/poledance
SECRET_KEY: ${SECRET_KEY}
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 15
REFRESH_TOKEN_EXPIRE_DAYS: 7
INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-}
INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
INSTAGRAM_POLL_INTERVAL: ${INSTAGRAM_POLL_INTERVAL:-1800}
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
volumes:
postgres_data:

41
mobile/.gitignore vendored
View File

@@ -1,41 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

View File

@@ -1,49 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import { RootNavigator } from './src/navigation/RootNavigator';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
export default function App() {
const notificationListener = useRef<Notifications.EventSubscription>();
const responseListener = useRef<Notifications.EventSubscription>();
useEffect(() => {
// Handle notifications received while app is in foreground
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('Notification received:', notification);
},
);
// Handle notification tap
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
console.log('Notification tapped:', response);
// TODO: navigate to relevant screen based on response.notification.request.content.data
},
);
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
return (
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</QueryClientProvider>
);
}

View File

@@ -1,50 +0,0 @@
{
"expo": {
"name": "Pole Dance Championships",
"slug": "poledance-championships",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": false,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#6C3FC5"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.yourorg.poledance",
"infoPlist": {
"UIBackgroundModes": ["remote-notification"]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#6C3FC5"
},
"package": "com.yourorg.poledance",
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/icon.png",
"color": "#6C3FC5",
"defaultChannel": "default"
}
]
],
"extra": {
"eas": {
"projectId": "YOUR_EAS_PROJECT_ID"
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,8 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

9246
mobile/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
{
"name": "mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@react-navigation/bottom-tabs": "^7.14.0",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"expo": "~54.0.33",
"expo-notifications": "~0.32.16",
"expo-secure-store": "^15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.71.2",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.16.0",
"react-native-web": "~0.21.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -1,20 +0,0 @@
import { LoginRequest, RegisterRequest, TokenResponse, User } from '../types/auth.types';
import { apiClient } from './client';
export const authApi = {
register: (data: RegisterRequest) =>
apiClient.post<TokenResponse>('/auth/register', data).then((r) => r.data),
login: (data: LoginRequest) =>
apiClient.post<TokenResponse>('/auth/login', data).then((r) => r.data),
refresh: (refreshToken: string) =>
apiClient
.post<TokenResponse>('/auth/refresh', { refresh_token: refreshToken })
.then((r) => r.data),
logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
me: () => apiClient.get<User>('/auth/me').then((r) => r.data),
};

View File

@@ -1,10 +0,0 @@
import { Championship } from '../types/championship.types';
import { apiClient } from './client';
export const championshipsApi = {
list: (params?: { status?: string; skip?: number; limit?: number }) =>
apiClient.get<Championship[]>('/championships', { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Championship>(`/championships/${id}`).then((r) => r.data),
};

View File

@@ -1,84 +0,0 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { tokenStorage } from '../utils/tokenStorage';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:8000/api/v1';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
});
// Attach access token to every request
apiClient.interceptors.request.use(async (config) => {
const token = await tokenStorage.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// On 401, try refreshing once then retry
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: string) => void;
reject: (reason?: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null = null) {
failedQueue.forEach((p) => {
if (error) {
p.reject(error);
} else {
p.resolve(token!);
}
});
failedQueue = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
await tokenStorage.saveTokens(data.access_token, data.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
processQueue(null, data.access_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
await tokenStorage.clearTokens();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);

View File

@@ -1,13 +0,0 @@
import { Registration, RegistrationCreate } from '../types/registration.types';
import { apiClient } from './client';
export const registrationsApi = {
submit: (data: RegistrationCreate) =>
apiClient.post<Registration>('/registrations', data).then((r) => r.data),
myRegistrations: () =>
apiClient.get<Registration[]>('/registrations/my').then((r) => r.data),
withdrawRegistration: (id: string) =>
apiClient.delete(`/registrations/${id}`),
};

View File

@@ -1,6 +0,0 @@
import { apiClient } from './client';
export const usersApi = {
updatePushToken: (userId: string, expoPushToken: string) =>
apiClient.patch(`/users/${userId}/push-token`, { expo_push_token: expoPushToken }),
};

View File

@@ -1,31 +0,0 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
const COLORS: Record<string, { bg: string; text: string }> = {
draft: { bg: '#F3F4F6', text: '#6B7280' },
open: { bg: '#D1FAE5', text: '#065F46' },
closed: { bg: '#FEE2E2', text: '#991B1B' },
completed: { bg: '#EDE9FE', text: '#5B21B6' },
submitted: { bg: '#FEF3C7', text: '#92400E' },
accepted: { bg: '#D1FAE5', text: '#065F46' },
rejected: { bg: '#FEE2E2', text: '#991B1B' },
waitlisted: { bg: '#DBEAFE', text: '#1E40AF' },
};
interface Props {
status: string;
}
export function StatusBadge({ status }: Props) {
const colors = COLORS[status] ?? { bg: '#F3F4F6', text: '#374151' };
return (
<View style={[styles.badge, { backgroundColor: colors.bg }]}>
<Text style={[styles.text, { color: colors.text }]}>{status.toUpperCase()}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: { borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, alignSelf: 'flex-start' },
text: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
});

View File

@@ -1,55 +0,0 @@
import { authApi } from '../api/auth.api';
import { LoginRequest, RegisterRequest } from '../types/auth.types';
import { tokenStorage } from '../utils/tokenStorage';
import { useAuthStore } from '../store/auth.store';
export function useAuth() {
const { user, isLoading, setUser, setLoading } = useAuthStore();
const initialize = async () => {
setLoading(true);
try {
const token = await tokenStorage.getAccessToken();
if (token) {
const me = await authApi.me();
setUser(me);
}
} catch {
await tokenStorage.clearTokens();
setUser(null);
} finally {
setLoading(false);
}
};
const login = async (data: LoginRequest) => {
const tokens = await authApi.login(data);
await tokenStorage.saveTokens(tokens.access_token, tokens.refresh_token);
const me = await authApi.me();
setUser(me);
return me;
};
const register = async (data: RegisterRequest) => {
const tokens = await authApi.register(data);
await tokenStorage.saveTokens(tokens.access_token, tokens.refresh_token);
const me = await authApi.me();
setUser(me);
return me;
};
const logout = async () => {
const refreshToken = await tokenStorage.getRefreshToken();
if (refreshToken) {
try {
await authApi.logout(refreshToken);
} catch {
// Ignore errors on logout
}
}
await tokenStorage.clearTokens();
setUser(null);
};
return { user, isLoading, initialize, login, register, logout };
}

View File

@@ -1,42 +0,0 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { usersApi } from '../api/users.api';
import { useAuthStore } from '../store/auth.store';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export function usePushNotifications() {
const user = useAuthStore((s) => s.user);
const registerToken = async () => {
if (!user) return;
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
});
}
const tokenData = await Notifications.getExpoPushTokenAsync();
await usersApi.updatePushToken(user.id, tokenData.data);
};
return { registerToken };
}

View File

@@ -1,59 +0,0 @@
import React, { useEffect } from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ChampionshipListScreen } from '../screens/championships/ChampionshipListScreen';
import { ChampionshipDetailScreen } from '../screens/championships/ChampionshipDetailScreen';
import { RegistrationFormScreen } from '../screens/registration/RegistrationFormScreen';
import { ProfileScreen } from '../screens/profile/ProfileScreen';
import { usePushNotifications } from '../hooks/usePushNotifications';
export type AppStackParamList = {
Championships: undefined;
ChampionshipDetail: { id: string };
RegistrationForm: { championshipId: string; championshipTitle: string };
Profile: undefined;
};
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator<AppStackParamList>();
function ChampionshipStack() {
return (
<Stack.Navigator>
<Stack.Screen
name="Championships"
component={ChampionshipListScreen}
options={{ title: 'Championships' }}
/>
<Stack.Screen
name="ChampionshipDetail"
component={ChampionshipDetailScreen}
options={{ title: 'Details' }}
/>
<Stack.Screen
name="RegistrationForm"
component={RegistrationFormScreen}
options={{ title: 'Register' }}
/>
</Stack.Navigator>
);
}
export function AppStack() {
const { registerToken } = usePushNotifications();
useEffect(() => {
registerToken();
}, []);
return (
<Tab.Navigator>
<Tab.Screen
name="ChampionshipTab"
component={ChampionshipStack}
options={{ headerShown: false, title: 'Schedule' }}
/>
<Tab.Screen name="ProfileTab" component={ProfileScreen} options={{ title: 'Profile' }} />
</Tab.Navigator>
);
}

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { LoginScreen } from '../screens/auth/LoginScreen';
import { RegisterScreen } from '../screens/auth/RegisterScreen';
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
const Stack = createNativeStackNavigator<AuthStackParamList>();
export function AuthStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
}

View File

@@ -1,28 +0,0 @@
import React, { useEffect } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { useAuth } from '../hooks/useAuth';
import { AuthStack } from './AuthStack';
import { AppStack } from './AppStack';
import { PendingApprovalScreen } from '../screens/auth/PendingApprovalScreen';
export function RootNavigator() {
const { user, isLoading, initialize } = useAuth();
useEffect(() => {
initialize();
}, []);
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
if (!user) return <AuthStack />;
if (user.status === 'pending') return <PendingApprovalScreen />;
if (user.status === 'rejected') return <AuthStack />;
return <AppStack />;
}

View File

@@ -1,17 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { championshipsApi } from '../api/championships.api';
export function useChampionships(status?: string) {
return useQuery({
queryKey: ['championships', status],
queryFn: () => championshipsApi.list({ status }),
});
}
export function useChampionshipDetail(id: string) {
return useQuery({
queryKey: ['championship', id],
queryFn: () => championshipsApi.get(id),
enabled: !!id,
});
}

View File

@@ -1,30 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { registrationsApi } from '../api/registrations.api';
import { RegistrationCreate } from '../types/registration.types';
export function useMyRegistrations() {
return useQuery({
queryKey: ['registrations', 'my'],
queryFn: registrationsApi.myRegistrations,
});
}
export function useSubmitRegistration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RegistrationCreate) => registrationsApi.submit(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['registrations', 'my'] });
},
});
}
export function useWithdrawRegistration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => registrationsApi.withdrawRegistration(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['registrations', 'my'] });
},
});
}

View File

@@ -1,104 +0,0 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useAuth } from '../../hooks/useAuth';
import { AuthStackParamList } from '../../navigation/AuthStack';
type Props = NativeStackScreenProps<AuthStackParamList, 'Login'>;
export function LoginScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Error', 'Please enter your email and password');
return;
}
setLoading(true);
try {
await login({ email, password });
} catch (err: any) {
const msg = err?.code === 'ECONNABORTED'
? `Cannot reach server at ${process.env.EXPO_PUBLIC_API_URL}.\nMake sure your phone is on the same Wi-Fi and Windows Firewall allows port 8000.`
: err?.response?.status === 401
? 'Invalid email or password'
: `Error: ${err?.message ?? 'Unknown error'}`;
Alert.alert('Login failed', msg);
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.title}>Pole Dance Championships</Text>
<Text style={styles.subtitle}>Member Portal</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Logging in...' : 'Log In'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.link}>Don&apos;t have an account? Register</Text>
</TouchableOpacity>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#fff' },
title: { fontSize: 26, fontWeight: '700', textAlign: 'center', marginBottom: 4 },
subtitle: { fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 40 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 14,
marginBottom: 14,
fontSize: 16,
},
button: {
backgroundColor: '#6C3FC5',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
link: { textAlign: 'center', color: '#6C3FC5', fontSize: 14 },
});

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useAuth } from '../../hooks/useAuth';
export function PendingApprovalScreen() {
const { logout } = useAuth();
return (
<View style={styles.container}>
<Text style={styles.emoji}></Text>
<Text style={styles.title}>Account Pending Approval</Text>
<Text style={styles.body}>
Your registration has been received. An organizer will review and approve your account.
You will receive a notification once approved.
</Text>
<TouchableOpacity style={styles.button} onPress={logout}>
<Text style={styles.buttonText}>Log Out</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
backgroundColor: '#fff',
},
emoji: { fontSize: 60, marginBottom: 24 },
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
body: { fontSize: 15, color: '#555', textAlign: 'center', lineHeight: 22, marginBottom: 40 },
button: {
borderWidth: 1,
borderColor: '#6C3FC5',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 32,
},
buttonText: { color: '#6C3FC5', fontWeight: '600' },
});

View File

@@ -1,116 +0,0 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useAuth } from '../../hooks/useAuth';
import { AuthStackParamList } from '../../navigation/AuthStack';
type Props = NativeStackScreenProps<AuthStackParamList, 'Register'>;
export function RegisterScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [fullName, setFullName] = useState('');
const [phone, setPhone] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const handleRegister = async () => {
if (!email || !password || !fullName) {
Alert.alert('Error', 'Email, password and full name are required');
return;
}
setLoading(true);
try {
await register({ email, password, full_name: fullName, phone: phone || undefined });
} catch (err: any) {
const msg = err?.response?.data?.detail ?? 'Registration failed';
Alert.alert('Error', msg);
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Create Account</Text>
<TextInput
style={styles.input}
placeholder="Full Name"
value={fullName}
onChangeText={setFullName}
/>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<TextInput
style={styles.input}
placeholder="Phone (optional)"
keyboardType="phone-pad"
value={phone}
onChangeText={setPhone}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Registering...' : 'Register'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.link}>Already have an account? Log In</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flexGrow: 1, justifyContent: 'center', padding: 24, backgroundColor: '#fff' },
title: { fontSize: 26, fontWeight: '700', textAlign: 'center', marginBottom: 32 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 14,
marginBottom: 14,
fontSize: 16,
},
button: {
backgroundColor: '#6C3FC5',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
link: { textAlign: 'center', color: '#6C3FC5', fontSize: 14 },
});

View File

@@ -1,118 +0,0 @@
import React from 'react';
import {
ActivityIndicator,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useChampionshipDetail } from '../../queries/useChampionships';
import { StatusBadge } from '../../components/StatusBadge';
import { formatDate } from '../../utils/dateFormatters';
import { isRegistrationOpen } from '../../utils/dateFormatters';
import { AppStackParamList } from '../../navigation/AppStack';
type Props = NativeStackScreenProps<AppStackParamList, 'ChampionshipDetail'>;
export function ChampionshipDetailScreen({ route, navigation }: Props) {
const { id } = route.params;
const { data: champ, isLoading } = useChampionshipDetail(id);
if (isLoading || !champ) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6C3FC5" />
</View>
);
}
const canRegister = isRegistrationOpen(
champ.registration_open_at,
champ.registration_close_at,
champ.status,
);
return (
<ScrollView style={styles.container}>
{champ.image_url ? (
<Image source={{ uri: champ.image_url }} style={styles.image} />
) : (
<View style={styles.imagePlaceholder} />
)}
<View style={styles.body}>
<View style={styles.row}>
<StatusBadge status={champ.status} />
</View>
<Text style={styles.title}>{champ.title}</Text>
{champ.event_date && (
<View style={styles.detail}>
<Text style={styles.detailLabel}>Date</Text>
<Text style={styles.detailValue}>{formatDate(champ.event_date)}</Text>
</View>
)}
{champ.location && (
<View style={styles.detail}>
<Text style={styles.detailLabel}>Location</Text>
<Text style={styles.detailValue}>{champ.location}</Text>
</View>
)}
{champ.registration_close_at && (
<View style={styles.detail}>
<Text style={styles.detailLabel}>Registration deadline</Text>
<Text style={styles.detailValue}>{formatDate(champ.registration_close_at)}</Text>
</View>
)}
{champ.description && (
<Text style={styles.description}>{champ.description}</Text>
)}
<TouchableOpacity
style={[styles.button, !canRegister && styles.buttonDisabled]}
disabled={!canRegister}
onPress={() =>
navigation.navigate('RegistrationForm', {
championshipId: champ.id,
championshipTitle: champ.title,
})
}
>
<Text style={styles.buttonText}>
{canRegister ? 'Register for this Championship' : 'Registration Closed'}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
image: { width: '100%', height: 220 },
imagePlaceholder: { width: '100%', height: 120, backgroundColor: '#E9D5FF' },
body: { padding: 20, gap: 12 },
row: { flexDirection: 'row' },
title: { fontSize: 22, fontWeight: '800', marginTop: 4 },
detail: { gap: 2 },
detailLabel: { fontSize: 12, color: '#888', fontWeight: '500', textTransform: 'uppercase' },
detailValue: { fontSize: 15, color: '#222' },
description: { fontSize: 15, color: '#444', lineHeight: 22, marginTop: 8 },
button: {
backgroundColor: '#6C3FC5',
borderRadius: 10,
padding: 16,
alignItems: 'center',
marginTop: 16,
},
buttonDisabled: { backgroundColor: '#C4B5FD' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});

View File

@@ -1,91 +0,0 @@
import React from 'react';
import {
ActivityIndicator,
FlatList,
Image,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useChampionships } from '../../queries/useChampionships';
import { StatusBadge } from '../../components/StatusBadge';
import { Championship } from '../../types/championship.types';
import { formatDate } from '../../utils/dateFormatters';
import { AppStackParamList } from '../../navigation/AppStack';
type Props = NativeStackScreenProps<AppStackParamList, 'Championships'>;
export function ChampionshipListScreen({ navigation }: Props) {
const { data, isLoading, refetch, isRefetching } = useChampionships();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6C3FC5" />
</View>
);
}
const renderItem = ({ item }: { item: Championship }) => (
<TouchableOpacity
style={styles.card}
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.id })}
>
{item.image_url ? (
<Image source={{ uri: item.image_url }} style={styles.image} />
) : (
<View style={styles.imagePlaceholder} />
)}
<View style={styles.cardBody}>
<Text style={styles.cardTitle} numberOfLines={2}>
{item.title}
</Text>
<Text style={styles.cardDate}>{formatDate(item.event_date)}</Text>
{item.location ? <Text style={styles.cardLocation}>{item.location}</Text> : null}
<StatusBadge status={item.status} />
</View>
</TouchableOpacity>
);
return (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#6C3FC5" />
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.emptyText}>No championships scheduled yet</Text>
</View>
}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
list: { padding: 16, gap: 12 },
card: {
backgroundColor: '#fff',
borderRadius: 12,
overflow: 'hidden',
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
image: { width: '100%', height: 160 },
imagePlaceholder: { width: '100%', height: 100, backgroundColor: '#E9D5FF' },
cardBody: { padding: 14, gap: 6 },
cardTitle: { fontSize: 17, fontWeight: '700' },
cardDate: { fontSize: 13, color: '#6C3FC5', fontWeight: '500' },
cardLocation: { fontSize: 13, color: '#555' },
emptyText: { color: '#888', fontSize: 15 },
});

View File

@@ -1,124 +0,0 @@
import React from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useAuth } from '../../hooks/useAuth';
import { useMyRegistrations, useWithdrawRegistration } from '../../queries/useRegistrations';
import { useChampionshipDetail } from '../../queries/useChampionships';
import { StatusBadge } from '../../components/StatusBadge';
import { formatDate } from '../../utils/dateFormatters';
import { Registration } from '../../types/registration.types';
function RegistrationItem({ reg }: { reg: Registration }) {
const { data: champ } = useChampionshipDetail(reg.championship_id);
const { mutate: withdraw } = useWithdrawRegistration();
const handleWithdraw = () => {
Alert.alert('Withdraw Registration', 'Are you sure?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Withdraw',
style: 'destructive',
onPress: () => withdraw(reg.id),
},
]);
};
return (
<View style={styles.regCard}>
<Text style={styles.regTitle} numberOfLines={2}>
{champ?.title ?? 'Loading...'}
</Text>
{champ?.event_date && (
<Text style={styles.regDate}>{formatDate(champ.event_date)}</Text>
)}
<Text style={styles.regCategory}>
{reg.category} · {reg.level}
</Text>
<View style={styles.regFooter}>
<StatusBadge status={reg.status} />
{reg.status === 'submitted' && (
<TouchableOpacity onPress={handleWithdraw}>
<Text style={styles.withdrawText}>Withdraw</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
export function ProfileScreen() {
const { user, logout } = useAuth();
const { data: registrations, isLoading } = useMyRegistrations();
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.name}>{user?.full_name}</Text>
<Text style={styles.email}>{user?.email}</Text>
<StatusBadge status={user?.role ?? 'member'} />
</View>
<Text style={styles.sectionTitle}>My Registrations</Text>
{isLoading ? (
<ActivityIndicator color="#6C3FC5" style={{ marginTop: 24 }} />
) : registrations?.length === 0 ? (
<Text style={styles.emptyText}>No registrations yet</Text>
) : (
registrations?.map((reg) => <RegistrationItem key={reg.id} reg={reg} />)
)}
<TouchableOpacity style={styles.logoutButton} onPress={logout}>
<Text style={styles.logoutText}>Log Out</Text>
</TouchableOpacity>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
header: {
backgroundColor: '#6C3FC5',
padding: 24,
paddingTop: 48,
gap: 6,
},
name: { fontSize: 22, fontWeight: '700', color: '#fff' },
email: { fontSize: 14, color: '#DDD6FE' },
sectionTitle: { fontSize: 16, fontWeight: '700', margin: 16, color: '#333' },
regCard: {
backgroundColor: '#fff',
marginHorizontal: 16,
marginBottom: 12,
borderRadius: 10,
padding: 16,
gap: 6,
elevation: 1,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
},
regTitle: { fontSize: 15, fontWeight: '700' },
regDate: { fontSize: 13, color: '#6C3FC5' },
regCategory: { fontSize: 13, color: '#555' },
regFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
withdrawText: { fontSize: 13, color: '#DC2626' },
emptyText: { textAlign: 'center', color: '#888', marginTop: 24 },
logoutButton: {
margin: 24,
borderWidth: 1,
borderColor: '#6C3FC5',
borderRadius: 8,
padding: 14,
alignItems: 'center',
},
logoutText: { color: '#6C3FC5', fontWeight: '600' },
});

View File

@@ -1,140 +0,0 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { AppStackParamList } from '../../navigation/AppStack';
import { useSubmitRegistration } from '../../queries/useRegistrations';
type Props = NativeStackScreenProps<AppStackParamList, 'RegistrationForm'>;
const CATEGORIES = ['Pole Sport', 'Pole Art', 'Pole Exotic', 'Doubles', 'Kids'];
const LEVELS = ['Beginner', 'Amateur', 'Semi-Pro', 'Professional'];
export function RegistrationFormScreen({ route, navigation }: Props) {
const { championshipId, championshipTitle } = route.params;
const [category, setCategory] = useState('');
const [level, setLevel] = useState('');
const [notes, setNotes] = useState('');
const { mutate: submit, isPending } = useSubmitRegistration();
const handleSubmit = () => {
if (!category || !level) {
Alert.alert('Required', 'Please select a category and level');
return;
}
submit(
{ championship_id: championshipId, category, level, notes: notes || undefined },
{
onSuccess: () => {
Alert.alert('Submitted!', 'Your registration has been received. You will be notified when the participant list is published.', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
},
onError: (err: any) => {
const msg = err?.response?.data?.detail ?? 'Submission failed';
Alert.alert('Error', msg);
},
},
);
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.champTitle}>{championshipTitle}</Text>
<Text style={styles.label}>Category *</Text>
<View style={styles.chips}>
{CATEGORIES.map((c) => (
<TouchableOpacity
key={c}
style={[styles.chip, category === c && styles.chipSelected]}
onPress={() => setCategory(c)}
>
<Text style={[styles.chipText, category === c && styles.chipTextSelected]}>{c}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.label}>Level *</Text>
<View style={styles.chips}>
{LEVELS.map((l) => (
<TouchableOpacity
key={l}
style={[styles.chip, level === l && styles.chipSelected]}
onPress={() => setLevel(l)}
>
<Text style={[styles.chipText, level === l && styles.chipTextSelected]}>{l}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.label}>Additional Notes</Text>
<TextInput
style={styles.textarea}
placeholder="Any additional information for the organizers..."
multiline
numberOfLines={4}
value={notes}
onChangeText={setNotes}
/>
<TouchableOpacity
style={[styles.button, isPending && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={isPending}
>
<Text style={styles.buttonText}>{isPending ? 'Submitting...' : 'Submit Registration'}</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { padding: 20, backgroundColor: '#fff', flexGrow: 1 },
champTitle: { fontSize: 18, fontWeight: '700', marginBottom: 24, color: '#333' },
label: { fontSize: 14, fontWeight: '600', color: '#444', marginBottom: 8, marginTop: 16 },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
borderWidth: 1.5,
borderColor: '#C4B5FD',
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 8,
},
chipSelected: { backgroundColor: '#6C3FC5', borderColor: '#6C3FC5' },
chipText: { fontSize: 13, color: '#6C3FC5' },
chipTextSelected: { color: '#fff', fontWeight: '600' },
textarea: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 15,
textAlignVertical: 'top',
minHeight: 100,
},
button: {
backgroundColor: '#6C3FC5',
borderRadius: 10,
padding: 16,
alignItems: 'center',
marginTop: 32,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});

View File

@@ -1,16 +0,0 @@
import { create } from 'zustand';
import { User } from '../types/auth.types';
interface AuthState {
user: User | null;
isLoading: boolean;
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
setUser: (user) => set({ user }),
setLoading: (isLoading) => set({ isLoading }),
}));

View File

@@ -1,26 +0,0 @@
export interface User {
id: string;
email: string;
full_name: string;
phone: string | null;
role: 'member' | 'organizer' | 'admin';
status: 'pending' | 'approved' | 'rejected';
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
full_name: string;
phone?: string;
}

View File

@@ -1,14 +0,0 @@
export interface Championship {
id: string;
title: string;
description: string | null;
location: string | null;
event_date: string | null;
registration_open_at: string | null;
registration_close_at: string | null;
status: 'draft' | 'open' | 'closed' | 'completed';
source: 'manual' | 'instagram';
image_url: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -1,18 +0,0 @@
export interface Registration {
id: string;
championship_id: string;
user_id: string;
category: string | null;
level: string | null;
notes: string | null;
status: 'submitted' | 'accepted' | 'rejected' | 'waitlisted';
submitted_at: string;
decided_at: string | null;
}
export interface RegistrationCreate {
championship_id: string;
category?: string;
level?: string;
notes?: string;
}

View File

@@ -1,33 +0,0 @@
export function formatDate(iso: string | null): string {
if (!iso) return 'TBA';
const d = new Date(iso);
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
export function formatDateTime(iso: string | null): string {
if (!iso) return 'TBA';
const d = new Date(iso);
return d.toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function isRegistrationOpen(
openAt: string | null,
closeAt: string | null,
status: string,
): boolean {
if (status !== 'open') return false;
const now = Date.now();
if (openAt && new Date(openAt).getTime() > now) return false;
if (closeAt && new Date(closeAt).getTime() < now) return false;
return true;
}

View File

@@ -1,19 +0,0 @@
import * as SecureStore from 'expo-secure-store';
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
export const tokenStorage = {
saveTokens: async (access: string, refresh: string): Promise<void> => {
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, access);
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refresh);
},
getAccessToken: (): Promise<string | null> =>
SecureStore.getItemAsync(ACCESS_TOKEN_KEY),
getRefreshToken: (): Promise<string | null> =>
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
clearTokens: async (): Promise<void> => {
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
},
};

View File

@@ -1,17 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@api/*": ["src/api/*"],
"@store/*": ["src/store/*"],
"@screens/*": ["src/screens/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
"@queries/*": ["src/queries/*"],
"@types/*": ["src/types/*"],
"@utils/*": ["src/utils/*"]
}
}
}

View File

@@ -1,8 +0,0 @@
@echo off
cd /d D:\PoleDanceApp\backend
echo Starting Pole Dance Championships Backend...
echo API docs: http://localhost:8000/docs
echo Health: http://localhost:8000/internal/health
echo.
.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
pause

View File

@@ -1,8 +0,0 @@
@echo off
cd /d D:\PoleDanceApp\mobile
echo Starting Expo Mobile App...
echo Scan the QR code with Expo Go on your phone.
echo Your phone must be on the same Wi-Fi as this computer (IP: 10.4.4.24)
echo.
npx expo start --lan
pause