Clear project — starting fresh from spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
14
.env.example
14
.env.example
@@ -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
47
.gitignore
vendored
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
310
dance-champ-app.md
Normal 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.
|
||||
@@ -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
41
mobile/.gitignore
vendored
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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
9246
mobile/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user