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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor inconsistency: payout_tx_hash length

Migration uses String(100) but ORM and other tables (PayoutTable, BuybackTable) use String(128) for transaction hashes. While unlikely to cause issues (Solana tx hashes are 88 chars base58), consistency is preferred.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/alembic/versions/003_add_milestones_table.py` at line 48, The new
milestones table migration defines the column payout_tx_hash as sa.String(100)
which is inconsistent with the ORM and other tables (PayoutTable, BuybackTable)
that use String(128); update the migration's column definition for
payout_tx_hash to sa.String(128) so transaction-hash lengths match across
schemas and keep migrations consistent with the ORM models.

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(),
),
Comment on lines +49 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Nullability mismatch: created_at and updated_at

Migration defines created_at and updated_at with nullable=True, but the ORM model (MilestoneTable) defines them with nullable=False. This inconsistency will cause constraint violations when the ORM attempts inserts without explicit timestamps, or schema validation failures.

Fix nullability to match ORM
         sa.Column(
             "created_at",
             sa.DateTime(timezone=True),
-            nullable=True,
+            nullable=False,
             server_default=sa.func.now(),
         ),
         sa.Column(
             "updated_at",
             sa.DateTime(timezone=True),
-            nullable=True,
+            nullable=False,
             server_default=sa.func.now(),
         ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/alembic/versions/003_add_milestones_table.py` around lines 49 - 60,
The migration defines created_at and updated_at as nullable=True but the ORM
model MilestoneTable expects non-nullable timestamps; update the Alembic
migration to make the columns non-nullable (set nullable=False for "created_at"
and "updated_at" columns in the migration) so the schema matches MilestoneTable
and avoids insert/constraint mismatches, keeping the
server_default=sa.func.now() intact to populate values when omitted.

)

op.create_index(
"ix_bounties_milestones_bounty_id",
"bounties_milestones",
["bounty_id"],
)
op.create_index(
"ix_bounties_milestones_status",
"bounties_milestones",
["status"],
)
Comment on lines +63 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing unique composite index on (bounty_id, milestone_number)

The ORM model defines a unique index ix_bmilestone_bounty_num on (bounty_id, milestone_number) to enforce that milestone numbers are unique per bounty. This index is missing from the migration, which will cause duplicate milestone numbers to be allowed and break milestone ordering logic.

Add the missing unique index
     op.create_index(
         "ix_bounties_milestones_status",
         "bounties_milestones",
         ["status"],
     )
+    op.create_index(
+        "ix_bmilestone_bounty_num",
+        "bounties_milestones",
+        ["bounty_id", "milestone_number"],
+        unique=True,
+    )

And in downgrade:

 def downgrade() -> None:
     """Drop the bounties_milestones table."""
+    op.drop_index("ix_bmilestone_bounty_num", table_name="bounties_milestones")
     op.drop_index("ix_bounties_milestones_status", table_name="bounties_milestones")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
op.create_index(
"ix_bounties_milestones_bounty_id",
"bounties_milestones",
["bounty_id"],
)
op.create_index(
"ix_bounties_milestones_status",
"bounties_milestones",
["status"],
)
op.create_index(
"ix_bounties_milestones_bounty_id",
"bounties_milestones",
["bounty_id"],
)
op.create_index(
"ix_bounties_milestones_status",
"bounties_milestones",
["status"],
)
op.create_index(
"ix_bmilestone_bounty_num",
"bounties_milestones",
["bounty_id", "milestone_number"],
unique=True,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/alembic/versions/003_add_milestones_table.py` around lines 63 - 72,
Add the missing unique composite index ix_bmilestone_bounty_num on the
bounties_milestones table to enforce uniqueness of (bounty_id,
milestone_number): in the migration's upgrade add an op.create_index call
creating ix_bmilestone_bounty_num on columns ["bounty_id", "milestone_number"]
with unique=True (alongside the existing ix_bounties_milestones_* indexes), and
in the downgrade add the corresponding op.drop_index("ix_bmilestone_bounty_num")
to remove it; locate these changes in the migration functions
(upgrade/downgrade) of 003_add_milestones_table.py.


# 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))
Comment on lines +798 to +816
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing import for List type annotation.

List[MilestoneResponse] is used at lines 798 and 807 but List is not imported from typing (line 8 only imports Optional). This will cause a NameError at runtime.

🐛 Proposed fix at line 8
-from typing import Optional
+from typing import List, Optional
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/bounties.py` around lines 798 - 816, The code uses the typing
name List in the create_milestones endpoint
(response_model=List[MilestoneResponse] and parameter data:
List[MilestoneCreate]) but List is not imported; add the missing import from
typing (e.g. from typing import List) near other imports so List is defined for
the create_milestones function and related type annotations (MilestoneResponse,
MilestoneCreate) resolve correctly.

Comment on lines +813 to +816
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Wrong exception type caught - UnauthorizedDisputeAccessError instead of UnauthorizedMilestoneAccessError.

The milestone endpoints catch UnauthorizedDisputeAccessError but MilestoneService raises UnauthorizedMilestoneAccessError. This means unauthorized access attempts will result in unhandled 500 errors instead of proper 400/403 responses.

🐛 Proposed fix - update exception handling
+from app.exceptions import (
+    BountyNotFoundError,
+    UnauthorizedMilestoneAccessError,
+    MilestoneValidationError,
+    MilestoneSequenceError,
+    MilestoneNotFoundError,
+)

# In each endpoint:
-    except (ValueError, UnauthorizedDisputeAccessError) as e:
+    except (ValueError, UnauthorizedMilestoneAccessError, MilestoneValidationError, MilestoneSequenceError) as e:
         raise HTTPException(status_code=400, detail=str(e))
+    except MilestoneNotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
     except BountyNotFoundError as e:
         raise HTTPException(status_code=404, detail=str(e))

Also applies to: 836-839, 858-861

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/bounties.py` around lines 813 - 816, Replace the incorrect
exception type UnauthorizedDisputeAccessError with
UnauthorizedMilestoneAccessError in the milestone endpoint except blocks so
MilestoneService's raises are caught; update the three occurrences (the except
blocks currently grouping ValueError and UnauthorizedDisputeAccessError around
the MilestoneService calls) to catch UnauthorizedMilestoneAccessError and
re-raise an appropriate HTTPException (400 or 403 as intended) with the error
message. Ensure the change is applied to all three locations noted so
unauthorized milestone access no longer produces an unhandled 500.



@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"
Comment on lines +11 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing REJECTED status for milestone lifecycle.

The MilestoneStatus enum only has PENDING, SUBMITTED, and APPROVED. There's no status for when an owner rejects a submitted milestone. Without this, a rejected milestone would need to be reset to PENDING for resubmission, but there's no transition path defined for that scenario.

Consider adding a REJECTED status to complete the lifecycle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/models/milestone.py` around lines 11 - 16, The Milestone
lifecycle enum is missing a REJECTED state; update the MilestoneStatus enum by
adding a REJECTED member (e.g., REJECTED = "rejected") so the lifecycle can
represent owner rejections and allow transitions from SUBMITTED to REJECTED and
then to PENDING for resubmission; ensure any switch/if logic that checks
MilestoneStatus (e.g., places that read/write MilestoneStatus or functions
handling status transitions) is updated to handle the new REJECTED value.



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)
Comment on lines +22 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

milestone_number field in MilestoneCreate is ignored by service.

The milestone_number field is required in MilestoneCreate (inherited from MilestoneBase), but MilestoneService.create_milestones overwrites it using enumerate (line 74 in milestone_service.py). This could confuse API consumers who expect their provided numbers to be respected.

Either make milestone_number optional in MilestoneCreate or document that it's auto-assigned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/models/milestone.py` around lines 22 - 24, The MilestoneCreate
schema currently requires milestone_number but
MilestoneService.create_milestones (which reassigns numbers with enumerate)
ignores it; either make milestone_number optional on
MilestoneCreate/MilestoneBase (e.g., allow None) or change create_milestones to
respect a provided milestone_number (use the provided value when present,
otherwise auto-assign with enumerate). Update the schema
(MilestoneCreate/milestone_number Field) or update the service logic in
MilestoneService.create_milestones to check each input's milestone_number before
overwriting so API behavior is consistent and unambiguous.



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