feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

Add user authentication with login/signup/protected routes, boss pokemon
detail fields and result team tracking, moves and abilities selector
components and API, run ownership and visibility controls, and various
UI improvements across encounters, run list, and journal pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -0,0 +1,62 @@
"""add boss pokemon details
Revision ID: l3a4b5c6d7e8
Revises: k2f3a4b5c6d7
Create Date: 2026-03-20 19:30:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "l3a4b5c6d7e8"
down_revision: str | Sequence[str] | None = "k2f3a4b5c6d7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Add ability reference
op.add_column(
"boss_pokemon",
sa.Column(
"ability_id", sa.Integer(), sa.ForeignKey("abilities.id"), nullable=True
),
)
op.create_index("ix_boss_pokemon_ability_id", "boss_pokemon", ["ability_id"])
# Add held item (plain string)
op.add_column(
"boss_pokemon",
sa.Column("held_item", sa.String(50), nullable=True),
)
# Add nature (plain string)
op.add_column(
"boss_pokemon",
sa.Column("nature", sa.String(20), nullable=True),
)
# Add move references (up to 4 moves)
for i in range(1, 5):
op.add_column(
"boss_pokemon",
sa.Column(
f"move{i}_id", sa.Integer(), sa.ForeignKey("moves.id"), nullable=True
),
)
op.create_index(f"ix_boss_pokemon_move{i}_id", "boss_pokemon", [f"move{i}_id"])
def downgrade() -> None:
for i in range(1, 5):
op.drop_index(f"ix_boss_pokemon_move{i}_id", "boss_pokemon")
op.drop_column("boss_pokemon", f"move{i}_id")
op.drop_column("boss_pokemon", "nature")
op.drop_column("boss_pokemon", "held_item")
op.drop_index("ix_boss_pokemon_ability_id", "boss_pokemon")
op.drop_column("boss_pokemon", "ability_id")

View File

@@ -0,0 +1,44 @@
"""add boss result team
Revision ID: m4b5c6d7e8f9
Revises: l3a4b5c6d7e8
Create Date: 2026-03-20 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "m4b5c6d7e8f9"
down_revision: str | Sequence[str] | None = "l3a4b5c6d7e8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"boss_result_team",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"boss_result_id",
sa.Integer(),
sa.ForeignKey("boss_results.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"encounter_id",
sa.Integer(),
sa.ForeignKey("encounters.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("level", sa.SmallInteger(), nullable=False),
)
def downgrade() -> None:
op.drop_table("boss_result_team")

View File

@@ -0,0 +1,37 @@
"""create users table
Revision ID: n5c6d7e8f9a0
Revises: m4b5c6d7e8f9
Create Date: 2026-03-20 22:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "n5c6d7e8f9a0"
down_revision: str | Sequence[str] | None = "m4b5c6d7e8f9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, unique=True, index=True),
sa.Column("display_name", sa.String(100), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("users")

View File

@@ -0,0 +1,60 @@
"""add owner_id and visibility to runs
Revision ID: o6d7e8f9a0b1
Revises: n5c6d7e8f9a0
Create Date: 2026-03-20 22:01:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "o6d7e8f9a0b1"
down_revision: str | Sequence[str] | None = "n5c6d7e8f9a0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Create visibility enum
visibility_enum = sa.Enum("public", "private", name="run_visibility")
visibility_enum.create(op.get_bind(), checkfirst=True)
# Add owner_id (nullable FK to users)
op.add_column(
"nuzlocke_runs",
sa.Column("owner_id", sa.UUID(), nullable=True),
)
op.create_foreign_key(
"fk_nuzlocke_runs_owner_id",
"nuzlocke_runs",
"users",
["owner_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_nuzlocke_runs_owner_id", "nuzlocke_runs", ["owner_id"])
# Add visibility column with default 'public'
op.add_column(
"nuzlocke_runs",
sa.Column(
"visibility",
visibility_enum,
nullable=False,
server_default="public",
),
)
def downgrade() -> None:
op.drop_column("nuzlocke_runs", "visibility")
op.drop_index("ix_nuzlocke_runs_owner_id", table_name="nuzlocke_runs")
op.drop_constraint("fk_nuzlocke_runs_owner_id", "nuzlocke_runs", type_="foreignkey")
op.drop_column("nuzlocke_runs", "owner_id")
# Drop the enum type
sa.Enum(name="run_visibility").drop(op.get_bind(), checkfirst=True)

View File

@@ -5,10 +5,13 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult
from app.models.boss_result_team import BossResultTeam
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
@@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses
router = APIRouter()
def _boss_pokemon_load_options():
"""Standard eager-loading options for BossPokemon relationships."""
return (
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3),
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4),
)
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
game = await session.get(Game, game_id)
if game is None:
@@ -53,7 +68,7 @@ async def list_bosses(
query = (
select(BossBattle)
.where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
.order_by(BossBattle.order)
)
@@ -71,6 +86,7 @@ async def reorder_bosses(
game_id: int,
data: BossReorderRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -101,7 +117,7 @@ async def reorder_bosses(
result = await session.execute(
select(BossBattle)
.where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
.order_by(BossBattle.order)
)
return result.scalars().all()
@@ -114,6 +130,7 @@ async def create_boss(
game_id: int,
data: BossBattleCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -133,7 +150,7 @@ async def create_boss(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
return result.scalar_one()
@@ -144,6 +161,7 @@ async def update_boss(
boss_id: int,
data: BossBattleUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -158,7 +176,7 @@ async def update_boss(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
boss = result.scalar_one_or_none()
if boss is None:
@@ -174,7 +192,7 @@ async def update_boss(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
return result.scalar_one()
@@ -184,6 +202,7 @@ async def delete_boss(
game_id: int,
boss_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -206,6 +225,7 @@ async def bulk_import_bosses(
game_id: int,
items: list[BulkBossItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -248,6 +268,7 @@ async def set_boss_team(
boss_id: int,
team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -272,6 +293,13 @@ async def set_boss_team(
level=item.level,
order=item.order,
condition_label=item.condition_label,
ability_id=item.ability_id,
held_item=item.held_item,
nature=item.nature,
move1_id=item.move1_id,
move2_id=item.move2_id,
move3_id=item.move3_id,
move4_id=item.move4_id,
)
session.add(bp)
@@ -286,7 +314,7 @@ async def set_boss_team(
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.options(*_boss_pokemon_load_options())
)
return result.scalar_one()
@@ -301,7 +329,10 @@ async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_ses
raise HTTPException(status_code=404, detail="Run not found")
result = await session.execute(
select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id)
select(BossResult)
.where(BossResult.run_id == run_id)
.options(selectinload(BossResult.team))
.order_by(BossResult.id)
)
return result.scalars().all()
@@ -313,6 +344,7 @@ async def create_boss_result(
run_id: int,
data: BossResultCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
@@ -322,12 +354,30 @@ async def create_boss_result(
if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found")
# Validate team encounter IDs belong to this run
if data.team:
encounter_ids = [t.encounter_id for t in data.team]
enc_result = await session.execute(
select(Encounter).where(
Encounter.id.in_(encounter_ids), Encounter.run_id == run_id
)
)
found_encounters = {e.id for e in enc_result.scalars().all()}
missing = [eid for eid in encounter_ids if eid not in found_encounters]
if missing:
raise HTTPException(
status_code=400,
detail=f"Encounters not found in this run: {missing}",
)
# Check for existing result (upsert)
existing = await session.execute(
select(BossResult).where(
select(BossResult)
.where(
BossResult.run_id == run_id,
BossResult.boss_battle_id == data.boss_battle_id,
)
.options(selectinload(BossResult.team))
)
result = existing.scalar_one_or_none()
@@ -335,6 +385,10 @@ async def create_boss_result(
result.result = data.result
result.attempts = data.attempts
result.completed_at = datetime.now(UTC) if data.result == "won" else None
# Clear existing team and add new
for tm in result.team:
await session.delete(tm)
await session.flush()
else:
result = BossResult(
run_id=run_id,
@@ -344,10 +398,26 @@ async def create_boss_result(
completed_at=datetime.now(UTC) if data.result == "won" else None,
)
session.add(result)
await session.flush()
# Add team members
for tm in data.team:
team_member = BossResultTeam(
boss_result_id=result.id,
encounter_id=tm.encounter_id,
level=tm.level,
)
session.add(team_member)
await session.commit()
await session.refresh(result)
return result
# Re-fetch with team loaded
fresh = await session.execute(
select(BossResult)
.where(BossResult.id == result.id)
.options(selectinload(BossResult.team))
)
return fresh.scalar_one()
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
@@ -355,6 +425,7 @@ async def delete_boss_result(
run_id: int,
result_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(BossResult).where(

View File

@@ -5,6 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.evolution import Evolution
@@ -35,6 +36,7 @@ async def create_encounter(
run_id: int,
data: EncounterCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
# Validate run exists
run = await session.get(NuzlockeRun, run_id)
@@ -137,6 +139,7 @@ async def update_encounter(
encounter_id: int,
data: EncounterUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
@@ -163,7 +166,9 @@ async def update_encounter(
@router.delete("/encounters/{encounter_id}", status_code=204)
async def delete_encounter(
encounter_id: int, session: AsyncSession = Depends(get_session)
encounter_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
@@ -195,6 +200,7 @@ async def delete_encounter(
async def bulk_randomize_encounters(
run_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
# 1. Validate run
run = await session.get(NuzlockeRun, run_id)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.game import Game
@@ -228,7 +229,11 @@ async def list_game_routes(
@router.post("", response_model=GameResponse, status_code=201)
async def create_game(data: GameCreate, session: AsyncSession = Depends(get_session)):
async def create_game(
data: GameCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
existing = await session.execute(select(Game).where(Game.slug == data.slug))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
@@ -244,7 +249,10 @@ async def create_game(data: GameCreate, session: AsyncSession = Depends(get_sess
@router.put("/{game_id}", response_model=GameResponse)
async def update_game(
game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session)
game_id: int,
data: GameUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
game = await session.get(Game, game_id)
if game is None:
@@ -269,7 +277,11 @@ async def update_game(
@router.delete("/{game_id}", status_code=204)
async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)):
async def delete_game(
game_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
)
@@ -323,7 +335,10 @@ async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
async def create_route(
game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session)
game_id: int,
data: RouteCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -339,6 +354,7 @@ async def reorder_routes(
game_id: int,
data: RouteReorderRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -365,6 +381,7 @@ async def update_route(
route_id: int,
data: RouteUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -385,6 +402,7 @@ async def delete_route(
game_id: int,
route_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -419,6 +437,7 @@ async def bulk_import_routes(
game_id: int,
items: list[BulkRouteItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
vg_id = await _get_version_group_id(session, game_id)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.evolution import Evolution
@@ -437,7 +438,9 @@ async def get_genlocke_lineages(
@router.post("", response_model=GenlockeResponse, status_code=201)
async def create_genlocke(
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
data: GenlockeCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
if not data.game_ids:
raise HTTPException(status_code=400, detail="At least one game is required")
@@ -568,6 +571,7 @@ async def advance_leg(
leg_order: int,
data: AdvanceLegRequest | None = None,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
# Load genlocke with legs
result = await session.execute(
@@ -822,6 +826,7 @@ async def update_genlocke(
genlocke_id: int,
data: GenlockeUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(Genlocke)
@@ -858,6 +863,7 @@ async def update_genlocke(
async def delete_genlocke(
genlocke_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
@@ -889,6 +895,7 @@ async def add_leg(
genlocke_id: int,
data: AddLegRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
@@ -931,6 +938,7 @@ async def remove_leg(
genlocke_id: int,
leg_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(GenlockeLeg).where(

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.boss_result import BossResult
from app.models.journal_entry import JournalEntry
@@ -45,6 +46,7 @@ async def create_journal_entry(
run_id: int,
data: JournalEntryCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
# Validate run exists
run = await session.get(NuzlockeRun, run_id)
@@ -97,6 +99,7 @@ async def update_journal_entry(
entry_id: UUID,
data: JournalEntryUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(JournalEntry).where(
@@ -135,6 +138,7 @@ async def delete_journal_entry(
run_id: int,
entry_id: UUID,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
):
result = await session.execute(
select(JournalEntry).where(

View File

@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.ability import Ability
from app.models.move import Move
from app.schemas.move import (
AbilityResponse,
MoveResponse,
PaginatedAbilityResponse,
PaginatedMoveResponse,
)
router = APIRouter()
@router.get("/moves", response_model=PaginatedMoveResponse)
async def list_moves(
search: str | None = None,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
session: AsyncSession = Depends(get_session),
):
query = select(Move)
if search:
query = query.where(Move.name.ilike(f"%{search}%"))
query = query.order_by(Move.name).offset(offset).limit(limit)
result = await session.execute(query)
items = result.scalars().all()
# Count total
count_query = select(func.count()).select_from(Move)
if search:
count_query = count_query.where(Move.name.ilike(f"%{search}%"))
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
return PaginatedMoveResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/moves/{move_id}", response_model=MoveResponse)
async def get_move(
move_id: int,
session: AsyncSession = Depends(get_session),
):
move = await session.get(Move, move_id)
if move is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Move not found")
return move
@router.get("/abilities", response_model=PaginatedAbilityResponse)
async def list_abilities(
search: str | None = None,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
session: AsyncSession = Depends(get_session),
):
query = select(Ability)
if search:
query = query.where(Ability.name.ilike(f"%{search}%"))
query = query.order_by(Ability.name).offset(offset).limit(limit)
result = await session.execute(query)
items = result.scalars().all()
# Count total
count_query = select(func.count()).select_from(Ability)
if search:
count_query = count_query.where(Ability.name.ilike(f"%{search}%"))
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
return PaginatedAbilityResponse(
items=items, total=total, limit=limit, offset=offset
)
@router.get("/abilities/{ability_id}", response_model=AbilityResponse)
async def get_ability(
ability_id: int,
session: AsyncSession = Depends(get_session),
):
ability = await session.get(Ability, ability_id)
if ability is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Ability not found")
return ability

View File

@@ -9,13 +9,16 @@ from app.api import (
genlockes,
health,
journal_entries,
moves_abilities,
pokemon,
runs,
stats,
users,
)
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(games.router, prefix="/games", tags=["games"])
api_router.include_router(pokemon.router, tags=["pokemon"])
api_router.include_router(evolutions.router, tags=["evolutions"])
@@ -25,4 +28,5 @@ api_router.include_router(genlockes.router, prefix="/genlockes", tags=["genlocke
api_router.include_router(encounters.router, tags=["encounters"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(bosses.router, tags=["bosses"])
api_router.include_router(moves_abilities.router, tags=["moves", "abilities"])
api_router.include_router(export.router, prefix="/export", tags=["export"])

View File

@@ -1,10 +1,12 @@
from datetime import UTC, datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.auth import AuthUser, get_current_user, require_auth
from app.core.database import get_session
from app.models.boss_result import BossResult
from app.models.encounter import Encounter
@@ -12,8 +14,10 @@ from app.models.evolution import Evolution
from app.models.game import Game
from app.models.genlocke import GenlockeLeg
from app.models.genlocke_transfer import GenlockeTransfer
from app.models.nuzlocke_run import NuzlockeRun
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
from app.models.user import User
from app.schemas.run import (
OwnerResponse,
RunCreate,
RunDetailResponse,
RunGenlockeContext,
@@ -157,41 +161,136 @@ async def _compute_lineage_suggestion(
return f"{base_name} {numeral}"
def _build_run_response(run: NuzlockeRun) -> RunResponse:
"""Build RunResponse with owner info if present."""
owner = None
if run.owner:
owner = OwnerResponse(id=run.owner.id, display_name=run.owner.display_name)
return RunResponse(
id=run.id,
game_id=run.game_id,
name=run.name,
status=run.status,
rules=run.rules,
hof_encounter_ids=run.hof_encounter_ids,
naming_scheme=run.naming_scheme,
visibility=run.visibility,
owner=owner,
started_at=run.started_at,
completed_at=run.completed_at,
)
def _check_run_access(
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
) -> None:
"""
Check if user can access the run.
Raises 403 for private runs if user is not owner.
If require_owner=True, always requires ownership (for mutations).
"""
if run.owner_id is None:
# Unowned runs are accessible by everyone (legacy)
if require_owner:
raise HTTPException(
status_code=403, detail="Only the run owner can perform this action"
)
return
user_id = UUID(user.id) if user else None
if require_owner:
if user_id != run.owner_id:
raise HTTPException(
status_code=403, detail="Only the run owner can perform this action"
)
return
if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
raise HTTPException(status_code=403, detail="This run is private")
@router.post("", response_model=RunResponse, status_code=201)
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
async def create_run(
data: RunCreate,
session: AsyncSession = Depends(get_session),
user: AuthUser = Depends(require_auth),
):
# Validate game exists
game = await session.get(Game, data.game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
# Ensure user exists in local DB
user_id = UUID(user.id)
db_user = await session.get(User, user_id)
if db_user is None:
db_user = User(id=user_id, email=user.email or "")
session.add(db_user)
run = NuzlockeRun(
game_id=data.game_id,
owner_id=user_id,
name=data.name,
status="active",
visibility=data.visibility,
rules=data.rules,
naming_scheme=data.naming_scheme,
)
session.add(run)
await session.commit()
await session.refresh(run)
return run
# Reload with owner relationship
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run.id)
.options(joinedload(NuzlockeRun.owner))
)
run = result.scalar_one()
return _build_run_response(run)
@router.get("", response_model=list[RunResponse])
async def list_runs(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc())
)
return result.scalars().all()
async def list_runs(
request: Request,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
"""
List runs. Shows public runs and user's own private runs.
"""
query = select(NuzlockeRun).options(joinedload(NuzlockeRun.owner))
if user:
user_id = UUID(user.id)
# Show public runs OR runs owned by current user
query = query.where(
(NuzlockeRun.visibility == RunVisibility.PUBLIC)
| (NuzlockeRun.owner_id == user_id)
)
else:
# Anonymous: only public runs
query = query.where(NuzlockeRun.visibility == RunVisibility.PUBLIC)
query = query.order_by(NuzlockeRun.started_at.desc())
result = await session.execute(query)
runs = result.scalars().all()
return [_build_run_response(run) for run in runs]
@router.get("/{run_id}", response_model=RunDetailResponse)
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
async def get_run(
run_id: int,
request: Request,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run_id)
.options(
joinedload(NuzlockeRun.game),
joinedload(NuzlockeRun.owner),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route),
@@ -201,6 +300,9 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Check visibility access
_check_run_access(run, user)
# Check if this run belongs to a genlocke
genlocke_context = None
leg_result = await session.execute(
@@ -262,11 +364,20 @@ async def update_run(
run_id: int,
data: RunUpdate,
session: AsyncSession = Depends(get_session),
user: AuthUser = Depends(require_auth),
):
run = await session.get(NuzlockeRun, run_id)
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run_id)
.options(joinedload(NuzlockeRun.owner))
)
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Check ownership for mutations (unowned runs allow anyone for backwards compat)
_check_run_access(run, user, require_owner=run.owner_id is not None)
update_data = data.model_dump(exclude_unset=True)
# Validate hof_encounter_ids if provided
@@ -352,16 +463,30 @@ async def update_run(
genlocke.status = "completed"
await session.commit()
await session.refresh(run)
return run
# Reload with owner relationship
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run.id)
.options(joinedload(NuzlockeRun.owner))
)
run = result.scalar_one()
return _build_run_response(run)
@router.delete("/{run_id}", status_code=204)
async def delete_run(run_id: int, session: AsyncSession = Depends(get_session)):
async def delete_run(
run_id: int,
session: AsyncSession = Depends(get_session),
user: AuthUser = Depends(require_auth),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Check ownership for deletion (unowned runs allow anyone for backwards compat)
_check_run_access(run, user, require_owner=run.owner_id is not None)
# Block deletion if run is linked to a genlocke leg
leg_result = await session.execute(
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)

View File

@@ -0,0 +1,106 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session
from app.models.user import User
from app.schemas.base import CamelModel
router = APIRouter()
class UserResponse(CamelModel):
id: UUID
email: str
display_name: str | None = None
@router.post("/me", response_model=UserResponse)
async def sync_current_user(
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_auth),
):
"""
Sync the current authenticated user from Supabase to local DB.
Creates user on first login, updates email if changed.
"""
user_id = UUID(auth_user.id)
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
# First login - create user record
user = User(
id=user_id,
email=auth_user.email or "",
display_name=None,
)
session.add(user)
elif auth_user.email and user.email != auth_user.email:
# Email changed in Supabase - update local record
user.email = auth_user.email
await session.commit()
await session.refresh(user)
return user
@router.get("/me", response_model=UserResponse)
async def get_current_user(
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_auth),
):
"""Get the current authenticated user's profile."""
user_id = UUID(auth_user.id)
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
# Auto-create if not exists (shouldn't happen if /me POST is called on login)
user = User(
id=user_id,
email=auth_user.email or "",
display_name=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
class UserUpdateRequest(CamelModel):
display_name: str | None = None
@router.patch("/me", response_model=UserResponse)
async def update_current_user(
data: UserUpdateRequest,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_auth),
):
"""Update the current user's profile (display name)."""
user_id = UUID(auth_user.id)
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
user = User(
id=user_id,
email=auth_user.email or "",
display_name=data.display_name,
)
session.add(user)
else:
if data.display_name is not None:
user.display_name = data.display_name
await session.commit()
await session.refresh(user)
return user

View File

@@ -0,0 +1,83 @@
from dataclasses import dataclass
import jwt
from fastapi import Depends, HTTPException, Request, status
from app.core.config import settings
@dataclass
class AuthUser:
"""Authenticated user info extracted from JWT."""
id: str # Supabase user UUID
email: str | None = None
role: str | None = None
def _extract_token(request: Request) -> str | None:
"""Extract Bearer token from Authorization header."""
auth_header = request.headers.get("Authorization")
if not auth_header:
return None
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1]
def _verify_jwt(token: str) -> dict | None:
"""Verify JWT against Supabase JWT secret. Returns payload or None."""
if not settings.supabase_jwt_secret:
return None
try:
payload = jwt.decode(
token,
settings.supabase_jwt_secret,
algorithms=["HS256"],
audience="authenticated",
)
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def get_current_user(request: Request) -> AuthUser | None:
"""
Extract and verify the current user from the request.
Returns AuthUser if valid token, None otherwise.
"""
token = _extract_token(request)
if not token:
return None
payload = _verify_jwt(token)
if not payload:
return None
# Supabase JWT has 'sub' as user ID
user_id = payload.get("sub")
if not user_id:
return None
return AuthUser(
id=user_id,
email=payload.get("email"),
role=payload.get("role"),
)
def require_auth(user: AuthUser | None = Depends(get_current_user)) -> AuthUser:
"""
Dependency that requires authentication.
Raises 401 if no valid token is present.
"""
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return user

View File

@@ -17,5 +17,10 @@ class Settings(BaseSettings):
# Database settings
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/nuzlocke"
# Supabase Auth
supabase_url: str | None = None
supabase_anon_key: str | None = None
supabase_jwt_secret: str | None = None
settings = Settings()

View File

@@ -2,6 +2,7 @@ from app.models.ability import Ability
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult
from app.models.boss_result_team import BossResultTeam
from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game
@@ -13,6 +14,7 @@ from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.models.user import User
from app.models.version_group import VersionGroup
__all__ = [
@@ -20,6 +22,7 @@ __all__ = [
"BossBattle",
"BossPokemon",
"BossResult",
"BossResultTeam",
"Encounter",
"Evolution",
"Game",
@@ -32,5 +35,6 @@ __all__ = [
"Pokemon",
"Route",
"RouteEncounter",
"User",
"VersionGroup",
]

View File

@@ -1,8 +1,18 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.ability import Ability
from app.models.boss_battle import BossBattle
from app.models.move import Move
from app.models.pokemon import Pokemon
class BossPokemon(Base):
__tablename__ = "boss_pokemon"
@@ -16,8 +26,24 @@ class BossPokemon(Base):
order: Mapped[int] = mapped_column(SmallInteger)
condition_label: Mapped[str | None] = mapped_column(String(100))
# Detail fields
ability_id: Mapped[int | None] = mapped_column(
ForeignKey("abilities.id"), index=True
)
held_item: Mapped[str | None] = mapped_column(String(50))
nature: Mapped[str | None] = mapped_column(String(20))
move1_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
move2_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
move3_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
move4_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
boss_battle: Mapped[BossBattle] = relationship(back_populates="pokemon")
pokemon: Mapped[Pokemon] = relationship()
ability: Mapped[Ability | None] = relationship()
move1: Mapped[Move | None] = relationship(foreign_keys=[move1_id])
move2: Mapped[Move | None] = relationship(foreign_keys=[move2_id])
move3: Mapped[Move | None] = relationship(foreign_keys=[move3_id])
move4: Mapped[Move | None] = relationship(foreign_keys=[move4_id])
def __repr__(self) -> str:
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"

View File

@@ -25,6 +25,12 @@ class BossResult(Base):
run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results")
boss_battle: Mapped[BossBattle] = relationship()
team: Mapped[list[BossResultTeam]] = relationship(
back_populates="boss_result", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<BossResult(id={self.id}, run_id={self.run_id}, boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
return (
f"<BossResult(id={self.id}, run_id={self.run_id}, "
f"boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
)

View File

@@ -0,0 +1,26 @@
from sqlalchemy import ForeignKey, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BossResultTeam(Base):
__tablename__ = "boss_result_team"
id: Mapped[int] = mapped_column(primary_key=True)
boss_result_id: Mapped[int] = mapped_column(
ForeignKey("boss_results.id", ondelete="CASCADE"), index=True
)
encounter_id: Mapped[int] = mapped_column(
ForeignKey("encounters.id", ondelete="CASCADE"), index=True
)
level: Mapped[int] = mapped_column(SmallInteger)
boss_result: Mapped[BossResult] = relationship(back_populates="team")
encounter: Mapped[Encounter] = relationship()
def __repr__(self) -> str:
return (
f"<BossResultTeam(id={self.id}, boss_result_id={self.boss_result_id}, "
f"encounter_id={self.encounter_id}, level={self.level})>"
)

View File

@@ -1,21 +1,46 @@
from datetime import datetime
from __future__ import annotations
from sqlalchemy import DateTime, ForeignKey, String, func
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.boss_result import BossResult
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.journal_entry import JournalEntry
from app.models.user import User
class RunVisibility(StrEnum):
PUBLIC = "public"
PRIVATE = "private"
class NuzlockeRun(Base):
__tablename__ = "nuzlocke_runs"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
owner_id: Mapped[UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
name: Mapped[str] = mapped_column(String(100))
status: Mapped[str] = mapped_column(
String(20), index=True
) # active, completed, failed
visibility: Mapped[RunVisibility] = mapped_column(
Enum(RunVisibility, name="run_visibility", create_constraint=False),
default=RunVisibility.PUBLIC,
server_default="public",
)
rules: Mapped[dict] = mapped_column(JSONB, default=dict)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -25,6 +50,7 @@ class NuzlockeRun(Base):
naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True)
game: Mapped[Game] = relationship(back_populates="runs")
owner: Mapped[User | None] = relationship(back_populates="runs")
encounters: Mapped[list[Encounter]] = relationship(back_populates="run")
boss_results: Mapped[list[BossResult]] = relationship(back_populates="run")
journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run")

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.nuzlocke_run import NuzlockeRun
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
runs: Mapped[list[NuzlockeRun]] = relationship(back_populates="owner")
def __repr__(self) -> str:
return f"<User(id={self.id}, email='{self.email}')>"

View File

@@ -4,6 +4,16 @@ from app.schemas.base import CamelModel
from app.schemas.pokemon import PokemonResponse
class MoveRef(CamelModel):
id: int
name: str
class AbilityRef(CamelModel):
id: int
name: str
class BossPokemonResponse(CamelModel):
id: int
pokemon_id: int
@@ -11,6 +21,19 @@ class BossPokemonResponse(CamelModel):
order: int
condition_label: str | None
pokemon: PokemonResponse
# Detail fields
ability_id: int | None = None
ability: AbilityRef | None = None
held_item: str | None = None
nature: str | None = None
move1_id: int | None = None
move2_id: int | None = None
move3_id: int | None = None
move4_id: int | None = None
move1: MoveRef | None = None
move2: MoveRef | None = None
move3: MoveRef | None = None
move4: MoveRef | None = None
class BossBattleResponse(CamelModel):
@@ -31,6 +54,12 @@ class BossBattleResponse(CamelModel):
pokemon: list[BossPokemonResponse] = []
class BossResultTeamMemberResponse(CamelModel):
id: int
encounter_id: int
level: int
class BossResultResponse(CamelModel):
id: int
run_id: int
@@ -38,6 +67,7 @@ class BossResultResponse(CamelModel):
result: str
attempts: int
completed_at: datetime | None
team: list[BossResultTeamMemberResponse] = []
# --- Input schemas ---
@@ -78,12 +108,26 @@ class BossPokemonInput(CamelModel):
level: int
order: int
condition_label: str | None = None
# Detail fields
ability_id: int | None = None
held_item: str | None = None
nature: str | None = None
move1_id: int | None = None
move2_id: int | None = None
move3_id: int | None = None
move4_id: int | None = None
class BossResultTeamMemberInput(CamelModel):
encounter_id: int
level: int
class BossResultCreate(CamelModel):
boss_battle_id: int
result: str
attempts: int = 1
team: list[BossResultTeamMemberInput] = []
class BossReorderItem(CamelModel):

View File

@@ -1,15 +1,23 @@
from datetime import datetime
from uuid import UUID
from app.models.nuzlocke_run import RunVisibility
from app.schemas.base import CamelModel
from app.schemas.encounter import EncounterDetailResponse
from app.schemas.game import GameResponse
class OwnerResponse(CamelModel):
id: UUID
display_name: str | None = None
class RunCreate(CamelModel):
game_id: int
name: str
rules: dict = {}
naming_scheme: str | None = None
visibility: RunVisibility = RunVisibility.PUBLIC
class RunUpdate(CamelModel):
@@ -18,6 +26,7 @@ class RunUpdate(CamelModel):
rules: dict | None = None
hof_encounter_ids: list[int] | None = None
naming_scheme: str | None = None
visibility: RunVisibility | None = None
class RunResponse(CamelModel):
@@ -28,6 +37,8 @@ class RunResponse(CamelModel):
rules: dict
hof_encounter_ids: list[int] | None = None
naming_scheme: str | None = None
visibility: RunVisibility
owner: OwnerResponse | None = None
started_at: datetime
completed_at: datetime | None

View File

@@ -87,7 +87,9 @@ RUN_DEFS = [
"name": "Kanto Heartbreak",
"status": "failed",
"progress": 0.45,
"rules": {"customRules": "- Hardcore mode: no items in battle\n- Set mode only"},
"rules": {
"customRules": "- Hardcore mode: no items in battle\n- Set mode only"
},
"started_days_ago": 30,
"ended_days_ago": 20,
},