feat(marketplace): moderation endpoints and age gate enforcement

This commit is contained in:
2026-09-15 20:46:50 +00:00
parent 33621a8e60
commit 397200818d
3 changed files with 357 additions and 0 deletions
+95
View File
@@ -27,6 +27,7 @@ from app.services.purchase import (
InvalidEscrowStateError,
ListingNotAvailableError,
ListingNotFoundError,
MatureContentRestrictedError,
PurchaseNotFoundError,
PurchaseService,
SelfPurchaseError,
@@ -39,6 +40,16 @@ router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def is_moderator_user(user: User) -> bool:
return (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
)
# ── Schemas ──
class CatalogItemCreate(BaseModel):
@@ -123,6 +134,15 @@ class RefundRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ListingModerateRequest(BaseModel):
action: str = Field(..., pattern="^(delist|remove)$")
reason: str = Field(..., min_length=1, max_length=500)
class PurchaseReversalRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
# ── Catalog Items ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
@@ -386,6 +406,7 @@ async def purchase_listing(
db=db,
buyer_id=current_user.id,
listing_id=listing_id,
buyer_is_adult=current_user.is_adult,
idempotency_key=idempotency_key,
)
except ListingNotFoundError as exc:
@@ -394,6 +415,8 @@ async def purchase_listing(
raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except MatureContentRestrictedError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
resp = PurchaseResponse.model_validate(purchase)
resp.granted_item_id = granted_item.id
@@ -510,3 +533,75 @@ async def get_my_creator_balance(
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
return cb
# ── Moderation ──
@router.post("/listings/{listing_id}/moderate", response_model=ListingResponse)
async def moderate_listing(
listing_id: int,
payload: ListingModerateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator action to delist or remove a listing with reason."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
if payload.action == "remove":
listing.status = ListingStatus.removed_by_moderation
else:
listing.status = ListingStatus.delisted
listing.updated_at = datetime.now()
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.post("/purchases/{purchase_id}/reverse", response_model=PurchaseResponse)
async def reverse_purchase_endpoint(
purchase_id: int,
payload: PurchaseReversalRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator/admin audited reversal of purchase."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
try:
reversed_purchase = await purchase_service.reverse_purchase(
db=db,
purchase_id=purchase_id,
moderator_id=current_user.id,
reason=payload.reason,
)
return reversed_purchase
except PurchaseNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+72
View File
@@ -53,6 +53,29 @@ class InvalidEscrowStateError(Exception):
pass
class MatureContentRestrictedError(Exception):
"""Raised when an unverified minor attempts to purchase mature content."""
pass
def is_item_mature(item: CatalogItem) -> bool:
"""Check whether a catalog item carries mature flags or is marked mature."""
if not item.metadata_json or not isinstance(item.metadata_json, dict):
return False
if item.metadata_json.get("is_mature") or item.metadata_json.get("mature"):
return True
flags = item.metadata_json.get("content_flags", [])
if isinstance(flags, list):
for f in flags:
if isinstance(f, dict) and f.get("mature"):
return True
if isinstance(f, str) and any(
m in f for m in ("gore", "sexual", "gambling", "hate", "explicit")
):
return True
return False
class PurchaseService:
@staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
@@ -93,6 +116,7 @@ class PurchaseService:
db: AsyncSession,
buyer_id: int,
listing_id: int,
buyer_is_adult: bool = True,
idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]:
@@ -123,6 +147,11 @@ class PurchaseService:
if catalog_item is None:
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
if is_item_mature(catalog_item) and not buyer_is_adult:
raise MatureContentRestrictedError(
"MATURE_CONTENT_REQUIRES_VERIFICATION: Buyer must be 18+ to purchase mature items"
)
# 3. Calculate fee split
price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price)
@@ -247,5 +276,48 @@ class PurchaseService:
await db.refresh(purchase)
return purchase
@classmethod
async def reverse_purchase(
cls,
db: AsyncSession,
purchase_id: int,
moderator_id: int,
reason: str,
) -> Purchase:
"""Moderator/admin audited reversal of purchase."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status in (PurchaseStatus.reversed, PurchaseStatus.refunded):
raise InvalidEscrowStateError(f"Purchase already {purchase.status.value}")
# Debit seller creator balance (from pending if in escrow, else available)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
if purchase.status == PurchaseStatus.pending_escrow:
cb.pending = max(0, cb.pending - purchase.seller_net)
else:
cb.available = max(0, cb.available - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Refund buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_reversal",
reference_id=str(purchase.id),
metadata={"moderator_id": moderator_id, "reason": reason},
commit=False,
)
purchase.status = PurchaseStatus.reversed
await db.commit()
await db.refresh(purchase)
return purchase
purchase_service = PurchaseService()