Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions backend/alembic/versions/003_add_milestones_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Add milestones table for T3 bounties.

Revision ID: 003_milestones
Revises: 002_disputes
Create Date: 2026-03-22
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "003_milestones"
down_revision: Union[str, None] = "002_disputes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Create the bounties_milestones table."""
op.create_table(
"bounties_milestones",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
nullable=False,
),
sa.Column(
"bounty_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("bounties.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("milestone_number", sa.Integer(), nullable=False),
sa.Column("description", sa.String(500), nullable=False),
sa.Column("percentage", sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
),
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payout_tx_hash", sa.String(100), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=True,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=True,
server_default=sa.func.now(),
),
)

op.create_index(
"ix_bounties_milestones_bounty_id",
"bounties_milestones",
["bounty_id"],
)
op.create_index(
"ix_bounties_milestones_status",
"bounties_milestones",
["status"],
)

# Add missing claim fields to bounties table
op.add_column("bounties", sa.Column("claimed_by", sa.String(length=100), nullable=True))
op.add_column("bounties", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("bounties", sa.Column("claim_deadline", sa.DateTime(timezone=True), nullable=True))


def downgrade() -> None:
"""Drop the bounties_milestones table."""
op.drop_index("ix_bounties_milestones_status", table_name="bounties_milestones")
op.drop_index("ix_bounties_milestones_bounty_id", table_name="bounties_milestones")
op.drop_table("bounties_milestones")

op.drop_column("bounties", "claim_deadline")
op.drop_column("bounties", "claimed_at")
op.drop_column("bounties", "claimed_by")
106 changes: 103 additions & 3 deletions backend/app/api/bounties.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
from app.services import review_service
from app.services import lifecycle_service
from app.services.bounty_search_service import BountySearchService
from app.services.milestone_service import MilestoneService
from app.models.milestone import (
MilestoneCreate,
MilestoneResponse,
MilestoneSubmit,
MilestoneListResponse,
)

async def _verify_bounty_ownership(bounty_id: str, user: UserResponse):
"""Check that the authenticated user owns the bounty before modification.
Expand Down Expand Up @@ -452,7 +459,7 @@
# ---------------------------------------------------------------------------


from pydantic import BaseModel, Field as PydanticField

Check failure on line 462 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E402)

app/api/bounties.py:462:1: E402 Module level import not at top of file

Check failure on line 462 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E402)

app/api/bounties.py:462:1: E402 Module level import not at top of file

Check failure on line 462 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E402)

app/api/bounties.py:462:1: E402 Module level import not at top of file


class ApprovalRequest(BaseModel):
Expand Down Expand Up @@ -630,13 +637,13 @@
# Lifecycle engine endpoints
# ---------------------------------------------------------------------------

from app.services.bounty_lifecycle_service import (
LifecycleError,
publish_bounty as _publish_bounty,
claim_bounty as _claim_bounty,
unclaim_bounty as _unclaim_bounty,
transition_status as _transition_status,
)

Check failure on line 646 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E402)

app/api/bounties.py:640:1: E402 Module level import not at top of file

Check failure on line 646 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E402)

app/api/bounties.py:640:1: E402 Module level import not at top of file

Check failure on line 646 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E402)

app/api/bounties.py:640:1: E402 Module level import not at top of file


class ClaimRequest(BaseModel):
Expand Down Expand Up @@ -672,7 +679,7 @@
await _verify_bounty_ownership(bounty_id, user)
actor_id = user.wallet_address or str(user.id)
try:
return _publish_bounty(bounty_id, actor_id=actor_id)
return await _publish_bounty(bounty_id, actor_id=actor_id)
except LifecycleError as exc:
code = 404 if exc.code == "NOT_FOUND" else 400
raise HTTPException(status_code=code, detail=exc.message)
Expand Down Expand Up @@ -701,7 +708,7 @@
claimer_id = user.wallet_address or str(user.id)
duration = body.claim_duration_hours if body else 168
try:
return _claim_bounty(bounty_id, claimer_id, claim_duration_hours=duration)
return await _claim_bounty(bounty_id, claimer_id, claim_duration_hours=duration)
except LifecycleError as exc:
code = 404 if exc.code == "NOT_FOUND" else 400
raise HTTPException(status_code=code, detail=exc.message)
Expand All @@ -724,7 +731,7 @@
) -> BountyResponse:
actor_id = user.wallet_address or str(user.id)
try:
return _unclaim_bounty(bounty_id, actor_id=actor_id, reason="manual")
return await _unclaim_bounty(bounty_id, actor_id=actor_id, reason="manual")
except LifecycleError as exc:
code = 404 if exc.code == "NOT_FOUND" else 400
raise HTTPException(status_code=code, detail=exc.message)
Expand Down Expand Up @@ -760,3 +767,96 @@
code = 404 if exc.code == "NOT_FOUND" else 400
raise HTTPException(status_code=code, detail=exc.message)


# ---------------------------------------------------------------------------
# Milestone endpoints
# ---------------------------------------------------------------------------


@router.get(
"/{bounty_id}/milestones",
response_model=MilestoneListResponse,
summary="List milestones for a bounty",
)
async def list_milestones(
bounty_id: str,
db: AsyncSession = Depends(get_db),
) -> MilestoneListResponse:
"""Return all milestones for a specific bounty."""
svc = MilestoneService(db)
milestones = await svc.get_milestones(bounty_id)
total_perc = sum(float(m.percentage) for m in milestones)
return MilestoneListResponse(
bounty_id=bounty_id,
milestones=milestones,
total_percentage=total_perc,
)


@router.post(
"/{bounty_id}/milestones",
response_model=List[MilestoneResponse],

Check failure on line 798 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:798:20: F821 Undefined name `List`

Check failure on line 798 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:798:20: F821 Undefined name `List`

Check failure on line 798 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:798:20: F821 Undefined name `List`
status_code=status.HTTP_201_CREATED,
summary="Define milestones for a bounty",
)
async def create_milestones(
bounty_id: str,
data: List[MilestoneCreate],

Check failure on line 804 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:804:11: F821 Undefined name `List`

Check failure on line 804 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:804:11: F821 Undefined name `List`

Check failure on line 804 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:804:11: F821 Undefined name `List`
db: AsyncSession = Depends(get_db),
user: UserResponse = Depends(get_current_user),
) -> List[MilestoneResponse]:

Check failure on line 807 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:807:6: F821 Undefined name `List`

Check failure on line 807 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:807:6: F821 Undefined name `List`

Check failure on line 807 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:807:6: F821 Undefined name `List`
"""Register milestone checkpoints for a T3 bounty. Total must be 100%."""
svc = MilestoneService(db)
try:
user_id = user.wallet_address or str(user.id)
return await svc.create_milestones(bounty_id, data, user_id)
except (ValueError, UnauthorizedDisputeAccessError) as e:

Check failure on line 813 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:813:25: F821 Undefined name `UnauthorizedDisputeAccessError`

Check failure on line 813 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:813:25: F821 Undefined name `UnauthorizedDisputeAccessError`

Check failure on line 813 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:813:25: F821 Undefined name `UnauthorizedDisputeAccessError`
raise HTTPException(status_code=400, detail=str(e))
except BountyNotFoundError as e:

Check failure on line 815 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:815:12: F821 Undefined name `BountyNotFoundError`

Check failure on line 815 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:815:12: F821 Undefined name `BountyNotFoundError`

Check failure on line 815 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:815:12: F821 Undefined name `BountyNotFoundError`
raise HTTPException(status_code=404, detail=str(e))


@router.post(
"/{bounty_id}/milestones/{milestone_id}/submit",
response_model=MilestoneResponse,
summary="Submit a milestone for review",
)
async def submit_milestone(
bounty_id: str,
milestone_id: str,
data: MilestoneSubmit,
db: AsyncSession = Depends(get_db),
user: UserResponse = Depends(get_current_user),
) -> MilestoneResponse:
"""Contributor submits a completed milestone for verification."""
svc = MilestoneService(db)
try:
user_id = user.wallet_address or str(user.id)
return await svc.submit_milestone(bounty_id, milestone_id, data, user_id)
except (ValueError, UnauthorizedDisputeAccessError) as e:

Check failure on line 836 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:836:25: F821 Undefined name `UnauthorizedDisputeAccessError`

Check failure on line 836 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:836:25: F821 Undefined name `UnauthorizedDisputeAccessError`

Check failure on line 836 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:836:25: F821 Undefined name `UnauthorizedDisputeAccessError`
raise HTTPException(status_code=400, detail=str(e))
except BountyNotFoundError as e:

Check failure on line 838 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:838:12: F821 Undefined name `BountyNotFoundError`

Check failure on line 838 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:838:12: F821 Undefined name `BountyNotFoundError`

Check failure on line 838 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:838:12: F821 Undefined name `BountyNotFoundError`
raise HTTPException(status_code=404, detail=str(e))


@router.post(
"/{bounty_id}/milestones/{milestone_id}/approve",
response_model=MilestoneResponse,
summary="Approve a milestone and release payout",
)
async def approve_milestone(
bounty_id: str,
milestone_id: str,
db: AsyncSession = Depends(get_db),
user: UserResponse = Depends(get_current_user),
) -> MilestoneResponse:
"""Owner approves a milestone, triggering proportional token transfer."""
svc = MilestoneService(db)
try:
user_id = user.wallet_address or str(user.id)
return await svc.approve_milestone(bounty_id, milestone_id, user_id)
except (ValueError, UnauthorizedDisputeAccessError) as e:

Check failure on line 858 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:858:25: F821 Undefined name `UnauthorizedDisputeAccessError`

Check failure on line 858 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:858:25: F821 Undefined name `UnauthorizedDisputeAccessError`

Check failure on line 858 in backend/app/api/bounties.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (F821)

app/api/bounties.py:858:25: F821 Undefined name `UnauthorizedDisputeAccessError`
raise HTTPException(status_code=400, detail=str(e))
except BountyNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))

2 changes: 1 addition & 1 deletion backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ async def init_db() -> None:
from app.models.submission import SubmissionDB # noqa: F401
from app.models.tables import ( # noqa: F401
PayoutTable, BuybackTable, ReputationHistoryTable,
BountySubmissionTable,
BountySubmissionTable, MilestoneTable,
)
from app.models.review import AIReviewScoreDB # noqa: F401
from app.models.lifecycle import BountyLifecycleLogDB # noqa: F401
Expand Down
21 changes: 21 additions & 0 deletions backend/app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,24 @@ def __init__(self, message: str, tx_hash: str | None = None) -> None:

class EscrowDoubleSpendError(EscrowError):
"""Raised when a funding transaction could not be confirmed on-chain."""


# ---------------------------------------------------------------------------
# Milestone exceptions
# ---------------------------------------------------------------------------


class MilestoneNotFoundError(Exception):
"""Raised when a milestone ID does not exist in the database."""


class MilestoneValidationError(Exception):
"""Raised when milestone data fails validation (e.g. percentages)."""


class MilestoneSequenceError(Exception):
"""Raised when milestones are submitted or approved out of order."""


class UnauthorizedMilestoneAccessError(Exception):
"""Raised when a non-authorized user attempts a restricted milestone action."""
7 changes: 6 additions & 1 deletion backend/app/models/bounty.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from typing import Optional, List

from pydantic import BaseModel, Field, field_validator

from app.models.milestone import MilestoneResponse, MilestoneCreate


# ---------------------------------------------------------------------------
# Enums
Expand Down Expand Up @@ -260,6 +262,7 @@ class BountyCreate(BountyBase):

description: str = Field("", max_length=DESCRIPTION_MAX_LENGTH) # Override default for creation
tier: BountyTier = BountyTier.T2 # Override default for creation
milestones: Optional[List[MilestoneCreate]] = None


class BountyUpdate(BaseModel):
Expand Down Expand Up @@ -309,6 +312,7 @@ class BountyDB(BaseModel):
claim_deadline: Optional[datetime] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
milestones: list[MilestoneResponse] = Field(default_factory=list)


class BountyResponse(BountyBase):
Expand All @@ -332,6 +336,7 @@ class BountyResponse(BountyBase):
model_config = {"from_attributes": True}
submissions: list[SubmissionResponse] = Field(default_factory=list)
submission_count: int = 0
milestones: list[MilestoneResponse] = Field(default_factory=list)


class BountyListItem(BaseModel):
Expand Down
5 changes: 5 additions & 0 deletions backend/app/models/bounty_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ class BountyTable(Base):
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Claim fields (T2/T3)
claimed_by = Column(String(100), nullable=True)
claimed_at = Column(DateTime(timezone=True), nullable=True)
claim_deadline = Column(DateTime(timezone=True), nullable=True)

search_vector = Column(
Text, nullable=True
) # Fallback for SQLite; TSVECTOR is PG-only
Expand Down
56 changes: 56 additions & 0 deletions backend/app/models/milestone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Milestone models for T3 bounties."""

import uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, List

from pydantic import BaseModel, Field, field_validator


class MilestoneStatus(str, Enum):
"""Lifecycle status of a milestone."""

PENDING = "pending"
SUBMITTED = "submitted"
APPROVED = "approved"


class MilestoneBase(BaseModel):
"""Base fields for all milestone models."""

milestone_number: int = Field(..., ge=1)
description: str = Field(..., min_length=1, max_length=1000)
percentage: float = Field(..., gt=0, le=100)


class MilestoneCreate(MilestoneBase):
"""Payload for creating a milestone."""
pass


class MilestoneSubmit(BaseModel):
"""Payload for submitting a milestone."""

notes: Optional[str] = Field(None, max_length=1000)


class MilestoneResponse(MilestoneBase):
"""API response for a milestone."""

id: uuid.UUID
bounty_id: uuid.UUID
status: MilestoneStatus
submitted_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
payout_tx_hash: Optional[str] = None

model_config = {"from_attributes": True}


class MilestoneListResponse(BaseModel):
"""List of milestones for a bounty."""

bounty_id: uuid.UUID
milestones: List[MilestoneResponse]
total_percentage: float
Loading
Loading