feat(frontend/backend): admin agent review table (#9634)

<!-- Clearly explain the need for these changes: -->
We need an admin agent approval UI for handling the submissions to the
marketplace

### Changes 🏗️
- Adds routes to the admin routes list
- Fixes the db query for submitting new versions of existing agents
- Add models for responses that include version details
- add the admin pages for agent
- Adds the Admin Agent Data Table
- Add all the new endpoints to the client.ts
Models changes
- convert the Submission status to an enum
- remove is_approved from models which was left incorrectly
- Add StoreListingWithVersions
<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Test the admin dashboard for
    - [x] Reject
    - [x] Accept
    - [x] Updating listing
    - [x] More version submissions

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
This commit is contained in:
Nicholas Tindle
2025-03-24 02:52:35 -05:00
committed by GitHub
parent 26984a7338
commit 7ba566e768
22 changed files with 1164 additions and 1150 deletions

View File

@@ -11,7 +11,6 @@ from autogpt_libs.feature_flag.client import (
initialize_launchdarkly,
shutdown_launchdarkly,
)
from prisma.enums import SubmissionStatus
import backend.data.block
import backend.data.db
@@ -19,12 +18,12 @@ import backend.data.graph
import backend.data.user
import backend.server.integrations.router
import backend.server.routers.v1
import backend.server.v2.admin.store_admin_routes
import backend.server.v2.library.db
import backend.server.v2.library.model
import backend.server.v2.library.routes
import backend.server.v2.otto.routes
import backend.server.v2.postmark.postmark
import backend.server.v2.store.admin_routes
import backend.server.v2.store.model
import backend.server.v2.store.routes
import backend.util.service
@@ -102,7 +101,7 @@ app.include_router(
backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store"
)
app.include_router(
backend.server.v2.store.admin_routes.router,
backend.server.v2.admin.store_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/store",
)
@@ -258,50 +257,13 @@ class AgentServer(backend.util.service.AppProcess):
return await backend.server.v2.store.routes.create_submission(request, user_id)
### ADMIN ###
@staticmethod
async def test_get_store_submissions(
status: SubmissionStatus | None = None,
search: str | None = None,
page: int = 1,
page_size: int = 20,
):
return await backend.server.v2.store.admin_routes.get_submissions(
status, search, page, page_size
)
@staticmethod
async def test_get_pending_store_submissions(
page: int = 1,
page_size: int = 20,
):
return await backend.server.v2.store.admin_routes.get_pending_submissions(
page, page_size
)
@staticmethod
async def test_get_store_submission_details(
store_listing_version_id: str,
):
return await backend.server.v2.store.admin_routes.get_submission_details(
store_listing_version_id
)
@staticmethod
async def test_get_listing_history(
listing_id: str,
page: int = 1,
page_size: int = 20,
):
return await backend.server.v2.store.admin_routes.get_listing_history(
listing_id, page, page_size
)
@staticmethod
async def test_review_store_listing(
request: backend.server.v2.store.model.ReviewSubmissionRequest,
user: autogpt_libs.auth.models.User,
):
return await backend.server.v2.store.admin_routes.review_submission(
return await backend.server.v2.admin.store_admin_routes.review_submission(
request.store_listing_version_id, request, user
)

View File

@@ -0,0 +1,100 @@
import logging
import typing
import autogpt_libs.auth.depends
import fastapi
import fastapi.responses
import prisma.enums
import backend.server.v2.store.db
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter(prefix="/admin", tags=["store", "admin"])
@router.get(
"/listings",
response_model=backend.server.v2.store.model.StoreListingsWithVersionsResponse,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def get_admin_listings_with_versions(
status: typing.Optional[prisma.enums.SubmissionStatus] = None,
search: typing.Optional[str] = None,
page: int = 1,
page_size: int = 20,
):
"""
Get store listings with their version history for admins.
This provides a consolidated view of listings with their versions,
allowing for an expandable UI in the admin dashboard.
Args:
status: Filter by submission status (PENDING, APPROVED, REJECTED)
search: Search by name, description, or user email
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreListingsWithVersionsResponse with listings and their versions
"""
try:
listings = await backend.server.v2.store.db.get_admin_listings_with_versions(
status=status,
search_query=search,
page=page,
page_size=page_size,
)
return listings
except Exception as e:
logger.exception("Error getting admin listings with versions: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving listings with versions"
},
)
@router.post(
"/submissions/{store_listing_version_id}/review",
response_model=backend.server.v2.store.model.StoreSubmission,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def review_submission(
store_listing_version_id: str,
request: backend.server.v2.store.model.ReviewSubmissionRequest,
user: typing.Annotated[
autogpt_libs.auth.models.User,
fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user),
],
):
"""
Review a store listing submission.
Args:
store_listing_version_id: ID of the submission to review
request: Review details including approval status and comments
user: Authenticated admin user performing the review
Returns:
StoreSubmission with updated review information
"""
try:
submission = await backend.server.v2.store.db.review_store_submission(
store_listing_version_id=store_listing_version_id,
is_approved=request.is_approved,
external_comments=request.comments,
internal_comments=request.internal_comments or "",
reviewer_id=user.user_id,
)
return submission
except Exception as e:
logger.exception("Error reviewing submission: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while reviewing the submission"},
)

View File

@@ -1,207 +0,0 @@
import logging
import typing
import autogpt_libs.auth.depends
import fastapi
import fastapi.responses
import prisma.enums
import backend.server.v2.store.db
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter(prefix="/admin", tags=["store", "admin"])
@router.get(
"/submissions",
response_model=backend.server.v2.store.model.StoreSubmissionsResponse,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def get_submissions(
status: typing.Optional[prisma.enums.SubmissionStatus] = None,
search: typing.Optional[str] = None,
page: int = 1,
page_size: int = 20,
):
"""
Get all store submissions with filtering options.
Admin only endpoint for managing agent submissions.
Args:
status: Filter by submission status (ALL, PENDING, APPROVED, REJECTED)
search: Search by name, creator, or description
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreSubmissionsResponse with filtered submissions
"""
try:
submissions = await backend.server.v2.store.db.get_admin_submissions(
status=status,
search_query=search,
page=page,
page_size=page_size,
)
return submissions
except Exception as e:
logger.exception("Error getting admin submissions: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving submissions"},
)
@router.get(
"/submissions/pending",
response_model=backend.server.v2.store.model.StoreSubmissionsResponse,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def get_pending_submissions(
page: int = 1,
page_size: int = 20,
):
"""
Get pending submissions that need admin review.
Convenience endpoint for the admin dashboard.
Args:
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreSubmissionsResponse with pending submissions
"""
try:
submissions = await backend.server.v2.store.db.get_pending_submissions(
page=page,
page_size=page_size,
)
return submissions
except Exception as e:
logger.exception("Error getting pending submissions: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving pending submissions"
},
)
@router.get(
"/submissions/{store_listing_version_id}",
response_model=backend.server.v2.store.model.StoreSubmission,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def get_submission_details(
store_listing_version_id: str,
):
"""
Get detailed information about a specific submission.
Args:
store_listing_version_id: ID of the submission version
Returns:
StoreSubmission with full details including internal comments
"""
try:
submission = await backend.server.v2.store.db.get_submission_details(
store_listing_version_id=store_listing_version_id,
)
return submission
except backend.server.v2.store.exceptions.SubmissionNotFoundError:
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Submission not found"},
)
except Exception as e:
logger.exception("Error getting submission details: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving submission details"},
)
@router.get(
"/submissions/listing/{listing_id}/history",
response_model=backend.server.v2.store.model.StoreSubmissionsResponse,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def get_listing_history(
listing_id: str,
page: int = 1,
page_size: int = 20,
):
"""
Get all submissions for a specific listing.
This shows the version history of a listing over time.
Args:
listing_id: The ID of the store listing
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreSubmissionsResponse with all versions of a specific listing
"""
try:
submissions = await backend.server.v2.store.db.get_listing_submissions_history(
listing_id=listing_id,
page=page,
page_size=page_size,
)
return submissions
except Exception as e:
logger.exception("Error getting listing history: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving listing history"},
)
@router.post(
"/submissions/{store_listing_version_id}/review",
response_model=backend.server.v2.store.model.StoreSubmission,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def review_submission(
store_listing_version_id: str,
request: backend.server.v2.store.model.ReviewSubmissionRequest,
user: typing.Annotated[
autogpt_libs.auth.models.User,
fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user),
],
):
"""
Review a store listing submission.
Args:
store_listing_version_id: ID of the submission to review
request: Review details including approval status and comments
user: Authenticated admin user performing the review
Returns:
StoreSubmission with updated review information
"""
try:
submission = await backend.server.v2.store.db.review_store_submission(
store_listing_version_id=store_listing_version_id,
is_approved=request.is_approved,
external_comments=request.comments,
internal_comments=request.internal_comments or "",
reviewer_id=user.user_id,
)
return submission
except Exception as e:
logger.exception("Error reviewing submission: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while reviewing the submission"},
)

View File

@@ -511,18 +511,34 @@ async def create_store_submission(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
listing = await prisma.models.StoreListing.prisma().find_first(
# Check if listing already exists for this agent
existing_listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
agentId=agent_id, owningUserId=user_id
)
)
if listing is not None:
logger.warning(f"Listing already exists for agent {agent_id}")
raise backend.server.v2.store.exceptions.ListingExistsError(
"Listing already exists for this agent"
if existing_listing is not None:
logger.info(
f"Listing already exists for agent {agent_id}, creating new version instead"
)
# Create the store listing
# Delegate to create_store_version which already handles this case correctly
return await create_store_version(
user_id=user_id,
agent_id=agent_id,
agent_version=agent_version,
store_listing_id=existing_listing.id,
name=name,
video_url=video_url,
image_urls=image_urls,
description=description,
sub_heading=sub_heading,
categories=categories,
changes_summary=changes_summary,
)
# If no existing listing, create a new one
data = prisma.types.StoreListingCreateInput(
slug=slug,
agentId=agent_id,
@@ -588,6 +604,117 @@ async def create_store_submission(
) from e
async def create_store_version(
user_id: str,
agent_id: str,
agent_version: int,
store_listing_id: str,
name: str,
video_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str = "Update Submission",
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new version for an existing store listing
Args:
user_id: ID of the authenticated user submitting the version
agent_id: ID of the agent being submitted
agent_version: Version of the agent being submitted
store_listing_id: ID of the existing store listing
name: Name of the agent
video_url: Optional URL to video demo
image_urls: List of image URLs for the listing
description: Description of the agent
categories: List of categories for the agent
changes_summary: Summary of changes from the previous version
Returns:
StoreSubmission: The created store submission
"""
logger.debug(
f"Creating new version for store listing {store_listing_id} for user {user_id}, agent {agent_id} v{agent_version}"
)
try:
# First verify the listing belongs to this user
listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
id=store_listing_id, owningUserId=user_id
),
include={"Versions": {"order_by": {"version": "desc"}, "take": 1}},
)
if not listing:
raise backend.server.v2.store.exceptions.ListingNotFoundError(
f"Store listing not found. User ID: {user_id}, Listing ID: {store_listing_id}"
)
# Verify the agent belongs to this user
agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput(
id=agent_id, version=agent_version, userId=user_id
)
)
if not agent:
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
# Get the latest version number
latest_version = listing.Versions[0] if listing.Versions else None
next_version = (latest_version.version + 1) if latest_version else 1
# Create a new version for the existing listing
new_version = await prisma.models.StoreListingVersion.prisma().create(
data=prisma.types.StoreListingVersionCreateInput(
version=next_version,
agentId=agent_id,
agentVersion=agent_version,
name=name,
videoUrl=video_url,
imageUrls=image_urls,
description=description,
categories=categories,
subHeading=sub_heading,
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
submittedAt=datetime.now(),
changesSummary=changes_summary,
storeListingId=store_listing_id,
)
)
logger.debug(
f"Created new version for listing {store_listing_id} of agent {agent_id}"
)
# Return submission details
return backend.server.v2.store.model.StoreSubmission(
agent_id=agent_id,
agent_version=agent_version,
name=name,
slug=listing.slug,
sub_heading=sub_heading,
description=description,
image_urls=image_urls,
date_submitted=datetime.now(),
status=prisma.enums.SubmissionStatus.PENDING,
runs=0,
rating=0.0,
store_listing_version_id=new_version.id,
changes_summary=changes_summary,
version=next_version,
)
except prisma.errors.PrismaError as e:
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create new store version"
) from e
async def create_store_review(
user_id: str,
store_listing_version_id: str,
@@ -974,13 +1101,14 @@ async def review_store_submission(
) from e
async def get_admin_submissions(
async def get_admin_listings_with_versions(
status: prisma.enums.SubmissionStatus | None = None,
search_query: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
"""Get store submissions for admins with filtering options.
) -> backend.server.v2.store.model.StoreListingsWithVersionsResponse:
"""
Get store listings for admins with all their versions.
Args:
status: Filter by submission status (PENDING, APPROVED, REJECTED)
@@ -989,170 +1117,144 @@ async def get_admin_submissions(
page_size: Number of items per page
Returns:
StoreSubmissionsResponse with submissions including internal comments
StoreListingsWithVersionsResponse with listings and their versions
"""
logger.debug(
f"Getting admin store submissions with status={status}, search={search_query}, page={page}"
f"Getting admin store listings with status={status}, search={search_query}, page={page}"
)
try:
# Build the where clause
where = prisma.types.StoreSubmissionWhereInput()
# Build the where clause for StoreListing
where_dict: prisma.types.StoreListingWhereInput = {
"isDeleted": False,
}
if status:
where["status"] = status
where_dict["Versions"] = {"some": {"submissionStatus": status}}
sanitized_query = sanitize_query(search_query)
if sanitized_query:
# Find users with matching email first
# Find users with matching email
matching_users = await prisma.models.User.prisma().find_many(
where={"email": {"contains": sanitized_query, "mode": "insensitive"}},
)
user_ids = [user.id for user in matching_users]
# Set up the OR conditions to search across multiple fields
where["OR"] = [
{"name": {"contains": sanitized_query, "mode": "insensitive"}},
{"description": {"contains": sanitized_query, "mode": "insensitive"}},
{"sub_heading": {"contains": sanitized_query, "mode": "insensitive"}},
# Set up OR conditions
where_dict["OR"] = [
{"slug": {"contains": sanitized_query, "mode": "insensitive"}},
{
"Versions": {
"some": {
"name": {"contains": sanitized_query, "mode": "insensitive"}
}
}
},
{
"Versions": {
"some": {
"description": {
"contains": sanitized_query,
"mode": "insensitive",
}
}
}
},
{
"Versions": {
"some": {
"subHeading": {
"contains": sanitized_query,
"mode": "insensitive",
}
}
}
},
]
# Add user_id condition if any users matched
if user_ids:
where["OR"].append({"user_id": {"in": user_ids}})
logger.debug(
f"Found {len(user_ids)} users matching query: {sanitized_query}"
)
where_dict["OR"].append({"owningUserId": {"in": user_ids}})
# Calculate pagination
skip = (page - 1) * page_size
# Query submissions from database - use the StoreSubmission view
submissions = await prisma.models.StoreSubmission.prisma().find_many(
# Create proper Prisma types for the query
where = prisma.types.StoreListingWhereInput(**where_dict)
include = prisma.types.StoreListingInclude(
Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing(
order_by=prisma.types._StoreListingVersion_version_OrderByInput(
version="desc"
)
),
OwningUser=True,
)
# Query listings with their versions
listings = await prisma.models.StoreListing.prisma().find_many(
where=where,
skip=skip,
take=page_size,
order=[{"date_submitted": "desc"}],
include=include,
order=[{"createdAt": "desc"}],
)
# Get total count for pagination
total = await prisma.models.StoreSubmission.prisma().count(where=where)
total = await prisma.models.StoreListing.prisma().count(where=where)
total_pages = (total + page_size - 1) // page_size
# Convert to response models - using all fields for admins
submission_models = []
for sub in submissions:
submission_model = backend.server.v2.store.model.StoreSubmission(
agent_id=sub.agent_id,
agent_version=sub.agent_version,
name=sub.name,
sub_heading=sub.sub_heading,
slug=sub.slug,
description=sub.description,
image_urls=sub.image_urls or [],
date_submitted=sub.date_submitted or datetime.now(tz=timezone.utc),
status=sub.status,
runs=sub.runs or 0,
rating=sub.rating or 0.0,
store_listing_version_id=sub.store_listing_version_id,
reviewer_id=sub.reviewer_id,
review_comments=sub.review_comments,
internal_comments=sub.internal_comments,
reviewed_at=sub.reviewed_at,
changes_summary=sub.changes_summary,
)
submission_models.append(submission_model)
logger.debug(f"Found {len(submission_models)} submissions for admin")
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=submission_models,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error fetching admin store submissions: {e}")
# Return empty response rather than exposing internal errors
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=0,
total_pages=0,
page_size=page_size,
),
)
async def get_listing_submissions_history(
listing_id: str,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
"""Get all submissions for a specific listing for admins.
Args:
listing_id: The ID of the store listing
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreSubmissionsResponse with all versions of a specific listing
"""
logger.debug(f"Getting submission history for listing {listing_id}")
try:
# Query StoreListingVersion directly since we need all versions for a listing
versions = await prisma.models.StoreListingVersion.prisma().find_many(
where={"storeListingId": listing_id},
order=[{"version": "desc"}],
skip=(page - 1) * page_size,
take=page_size,
include={"StoreListing": True},
)
# Get total count for pagination
total = await prisma.models.StoreListingVersion.prisma().count(
where={"storeListingId": listing_id}
)
total_pages = (total + page_size - 1) // page_size
# Convert to StoreSubmission models
submissions = []
for version in versions:
if not version.StoreListing:
continue
submissions.append(
backend.server.v2.store.model.StoreSubmission(
# Convert to response models
listings_with_versions = []
for listing in listings:
versions: list[backend.server.v2.store.model.StoreSubmission] = []
# If we have versions, turn them into StoreSubmission models
for version in listing.Versions or []:
version_model = backend.server.v2.store.model.StoreSubmission(
agent_id=version.agentId,
agent_version=version.agentVersion,
name=version.name,
sub_heading=version.subHeading,
slug=version.StoreListing.slug,
slug=listing.slug,
description=version.description,
image_urls=version.imageUrls or [],
date_submitted=version.submittedAt or version.createdAt,
status=version.submissionStatus,
runs=0, # We don't have this data here
rating=0.0, # We don't have this data here
runs=0, # Default values since we don't have this data here
rating=0.0, # Default values since we don't have this data here
store_listing_version_id=version.id,
reviewer_id=version.reviewerId,
review_comments=version.reviewComments,
internal_comments=version.internalComments,
reviewed_at=version.reviewedAt,
changes_summary=version.changesSummary,
version=version.version,
)
versions.append(version_model)
# Get the latest version (first in the sorted list)
latest_version = versions[0] if versions else None
creator_email = listing.OwningUser.email if listing.OwningUser else None
listing_with_versions = (
backend.server.v2.store.model.StoreListingWithVersions(
listing_id=listing.id,
slug=listing.slug,
agent_id=listing.agentId,
agent_version=listing.agentVersion,
active_version_id=listing.activeVersionId,
has_approved_version=listing.hasApprovedVersion,
creator_email=creator_email,
latest_version=latest_version,
versions=versions,
)
)
logger.debug(
f"Found {len(submissions)} version history for listing {listing_id}"
)
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=submissions,
listings_with_versions.append(listing_with_versions)
logger.debug(f"Found {len(listings_with_versions)} listings for admin")
return backend.server.v2.store.model.StoreListingsWithVersionsResponse(
listings=listings_with_versions,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
@@ -1161,9 +1263,10 @@ async def get_listing_submissions_history(
),
)
except Exception as e:
logger.error(f"Error fetching listing submission history: {e}")
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
logger.error(f"Error fetching admin store listings: {e}")
# Return empty response rather than exposing internal errors
return backend.server.v2.store.model.StoreListingsWithVersionsResponse(
listings=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=0,
@@ -1171,104 +1274,3 @@ async def get_listing_submissions_history(
page_size=page_size,
),
)
async def get_pending_submissions(
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
"""Convenience function to get pending submissions for the admin dashboard.
Args:
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreSubmissionsResponse with submissions that need review
"""
return await get_admin_submissions(
status=prisma.enums.SubmissionStatus.PENDING,
page=page,
page_size=page_size,
)
async def get_submission_details(
store_listing_version_id: str,
) -> backend.server.v2.store.model.StoreSubmission:
"""Get detailed information about a specific submission for admins.
Args:
store_listing_version_id: ID of the submission version
Returns:
StoreSubmission with full details including internal comments
"""
logger.debug(f"Getting submission details for {store_listing_version_id}")
try:
# First try getting from the view for efficiency
submission = await prisma.models.StoreSubmission.prisma().find_first(
where={"store_listing_version_id": store_listing_version_id}
)
if submission:
return backend.server.v2.store.model.StoreSubmission(
agent_id=submission.agent_id,
agent_version=submission.agent_version,
name=submission.name,
sub_heading=submission.sub_heading,
slug=submission.slug,
description=submission.description,
image_urls=submission.image_urls or [],
date_submitted=submission.date_submitted
or datetime.now(tz=timezone.utc),
status=submission.status,
runs=submission.runs or 0,
rating=submission.rating or 0.0,
store_listing_version_id=submission.store_listing_version_id,
reviewer_id=submission.reviewer_id,
review_comments=submission.review_comments,
internal_comments=submission.internal_comments,
reviewed_at=submission.reviewed_at,
changes_summary=submission.changes_summary,
)
# If not found in the view, try getting it directly
version = await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id},
include={"StoreListing": True},
)
if not version or not version.StoreListing:
raise backend.server.v2.store.exceptions.SubmissionNotFoundError(
f"Submission {store_listing_version_id} not found"
)
return backend.server.v2.store.model.StoreSubmission(
agent_id=version.agentId,
agent_version=version.agentVersion,
name=version.name,
sub_heading=version.subHeading,
slug=version.StoreListing.slug,
description=version.description,
image_urls=version.imageUrls or [],
date_submitted=version.submittedAt or version.createdAt,
status=version.submissionStatus,
runs=0, # We don't have this data here
rating=0.0, # We don't have this data here
store_listing_version_id=version.id,
reviewer_id=version.reviewerId,
review_comments=version.reviewComments,
internal_comments=version.internalComments,
reviewed_at=version.reviewedAt,
changes_summary=version.changesSummary,
)
except backend.server.v2.store.exceptions.SubmissionNotFoundError:
raise
except Exception as e:
logger.error(f"Error fetching submission details: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch submission details"
) from e

View File

@@ -70,6 +70,12 @@ class ProfileNotFoundError(StoreError):
pass
class ListingNotFoundError(StoreError):
"""Raised when a store listing is not found"""
pass
class SubmissionNotFoundError(StoreError):
"""Raised when a submission is not found"""

View File

@@ -120,6 +120,13 @@ class StoreSubmission(pydantic.BaseModel):
runs: int
rating: float
store_listing_version_id: str | None = None
version: int | None = None # Actual version number from the database
reviewer_id: str | None = None
review_comments: str | None = None # External comments visible to creator
internal_comments: str | None = None # Private notes for admin use only
reviewed_at: datetime.datetime | None = None
changes_summary: str | None = None
reviewer_id: str | None = None
review_comments: str | None = None # External comments visible to creator
@@ -133,6 +140,27 @@ class StoreSubmissionsResponse(pydantic.BaseModel):
pagination: Pagination
class StoreListingWithVersions(pydantic.BaseModel):
"""A store listing with its version history"""
listing_id: str
slug: str
agent_id: str
agent_version: int
active_version_id: str | None = None
has_approved_version: bool = False
creator_email: str | None = None
latest_version: StoreSubmission | None = None
versions: list[StoreSubmission] = []
class StoreListingsWithVersionsResponse(pydantic.BaseModel):
"""Response model for listings with version history"""
listings: list[StoreListingWithVersions]
pagination: Pagination
class StoreSubmissionRequest(pydantic.BaseModel):
agent_id: str
agent_version: int

View File

@@ -8,8 +8,8 @@ const sidebarLinkGroups = [
{
links: [
{
text: "Agent Management",
href: "/admin/agents",
text: "Marketplace Management",
href: "/admin/marketplace",
icon: <Users className="h-6 w-6" />,
},
{
@@ -32,7 +32,7 @@ export default function AdminLayout({
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen w-screen max-w-[1360px] flex-col lg:flex-row">
<div className="flex min-h-screen w-screen flex-col lg:flex-row">
<Sidebar linkGroups={sidebarLinkGroups} />
<div className="flex-1 pl-4">{children}</div>
</div>

View File

@@ -0,0 +1,58 @@
"use server";
import { revalidatePath } from "next/cache";
import BackendApi from "@/lib/autogpt-server-api";
import {
NotificationPreferenceDTO,
StoreListingsWithVersionsResponse,
StoreSubmissionsResponse,
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
export async function approveAgent(formData: FormData) {
const data = {
store_listing_version_id: formData.get("id") as string,
is_approved: true,
comments: formData.get("comments") as string,
};
const api = new BackendApi();
await api.reviewSubmissionAdmin(data.store_listing_version_id, data);
revalidatePath("/admin/marketplace");
}
export async function rejectAgent(formData: FormData) {
const data = {
store_listing_version_id: formData.get("id") as string,
is_approved: false,
comments: formData.get("comments") as string,
internal_comments: formData.get("internal_comments") as string,
};
const api = new BackendApi();
await api.reviewSubmissionAdmin(data.store_listing_version_id, data);
revalidatePath("/admin/marketplace");
}
export async function getAdminListingsWithVersions(
status?: SubmissionStatus,
search?: string,
page: number = 1,
pageSize: number = 20,
): Promise<StoreListingsWithVersionsResponse> {
const data: Record<string, any> = {
page,
page_size: pageSize,
};
if (status) {
data.status = status;
}
if (search) {
data.search = search;
}
const api = new BackendApi();
const response = await api.getAdminListingsWithVersions(data);
return response;
}

View File

@@ -1,25 +1,62 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
import type { SubmissionStatus } from "@/lib/autogpt-server-api/types";
import { AdminAgentsDataTable } from "@/components/admin/marketplace/admin-agents-data-table";
import React from "react";
// import { getReviewableAgents } from "@/components/admin/marketplace/actions";
// import AdminMarketplaceAgentList from "@/components/admin/marketplace/AdminMarketplaceAgentList";
// import AdminFeaturedAgentsControl from "@/components/admin/marketplace/AdminFeaturedAgentsControl";
import { Separator } from "@/components/ui/separator";
async function AdminMarketplace() {
// const reviewableAgents = await getReviewableAgents();
async function AdminMarketplaceDashboard({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
const status = searchParams.status as SubmissionStatus | undefined;
const search = searchParams.search;
return (
<>
{/* <AdminMarketplaceAgentList agents={reviewableAgents.items} />
<Separator className="my-4" />
<AdminFeaturedAgentsControl className="mt-4" /> */}
</>
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Marketplace Management</h1>
<p className="text-gray-500">
Unified view for marketplace management and approval history
</p>
</div>
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading submissions...</div>
}
>
<AdminAgentsDataTable
initialPage={page}
initialStatus={status}
initialSearch={search}
/>
</Suspense>
</div>
</div>
);
}
export default async function AdminDashboardPage() {
export default async function AdminMarketplacePage({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedAdminMarketplace = await withAdminAccess(AdminMarketplace);
return <ProtectedAdminMarketplace />;
const ProtectedAdminMarketplace = await withAdminAccess(
AdminMarketplaceDashboard,
);
return <ProtectedAdminMarketplace searchParams={searchParams} />;
}

View File

@@ -1,149 +0,0 @@
// "use client";
// import {
// Dialog,
// DialogContent,
// DialogClose,
// DialogFooter,
// DialogHeader,
// DialogTitle,
// DialogTrigger,
// } from "@/components/ui/dialog";
// import { Button } from "@/components/ui/button";
// import {
// MultiSelector,
// MultiSelectorContent,
// MultiSelectorInput,
// MultiSelectorItem,
// MultiSelectorList,
// MultiSelectorTrigger,
// } from "@/components/ui/multiselect";
// import { Controller, useForm } from "react-hook-form";
// import {
// Select,
// SelectContent,
// SelectItem,
// SelectTrigger,
// SelectValue,
// } from "@/components/ui/select";
// import { useState } from "react";
// import { addFeaturedAgent } from "./actions";
// import { Agent } from "@/lib/marketplace-api/types";
// type FormData = {
// agent: string;
// categories: string[];
// };
// export const AdminAddFeaturedAgentDialog = ({
// categories,
// agents,
// }: {
// categories: string[];
// agents: Agent[];
// }) => {
// const [selectedAgent, setSelectedAgent] = useState<string>("");
// const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// const {
// control,
// handleSubmit,
// watch,
// setValue,
// formState: { errors },
// } = useForm<FormData>({
// defaultValues: {
// agent: "",
// categories: [],
// },
// });
// return (
// <Dialog>
// <DialogTrigger asChild>
// <Button variant="outline" size="sm">
// Add Featured Agent
// </Button>
// </DialogTrigger>
// <DialogContent>
// <DialogHeader>
// <DialogTitle>Add Featured Agent</DialogTitle>
// </DialogHeader>
// <div className="flex flex-col gap-4">
// <Controller
// name="agent"
// control={control}
// rules={{ required: true }}
// render={({ field }) => (
// <div>
// <label htmlFor={field.name}>Agent</label>
// <Select
// onValueChange={(value) => {
// field.onChange(value);
// setSelectedAgent(value);
// }}
// value={field.value || ""}
// >
// <SelectTrigger>
// <SelectValue placeholder="Select an agent" />
// </SelectTrigger>
// <SelectContent>
// {/* Populate with agents */}
// {agents.map((agent) => (
// <SelectItem key={agent.id} value={agent.id}>
// {agent.name}
// </SelectItem>
// ))}
// </SelectContent>
// </Select>
// </div>
// )}
// />
// <Controller
// name="categories"
// control={control}
// render={({ field }) => (
// <MultiSelector
// values={field.value || []}
// onValuesChange={(values) => {
// field.onChange(values);
// setSelectedCategories(values);
// }}
// >
// <MultiSelectorTrigger>
// <MultiSelectorInput placeholder="Select categories" />
// </MultiSelectorTrigger>
// <MultiSelectorContent>
// <MultiSelectorList>
// {categories.map((category) => (
// <MultiSelectorItem key={category} value={category}>
// {category}
// </MultiSelectorItem>
// ))}
// </MultiSelectorList>
// </MultiSelectorContent>
// </MultiSelector>
// )}
// />
// </div>
// <DialogFooter>
// <DialogClose asChild>
// <Button variant="outline">Cancel</Button>
// </DialogClose>
// <DialogClose asChild>
// <Button
// type="submit"
// onClick={async () => {
// // Handle adding the featured agent
// await addFeaturedAgent(selectedAgent, selectedCategories);
// // close the dialog
// }}
// >
// Add
// </Button>
// </DialogClose>
// </DialogFooter>
// </DialogContent>
// </Dialog>
// );
// };

View File

@@ -1,74 +0,0 @@
// import { Button } from "@/components/ui/button";
// import {
// getFeaturedAgents,
// removeFeaturedAgent,
// getCategories,
// getNotFeaturedAgents,
// } from "./actions";
// import FeaturedAgentsTable from "./FeaturedAgentsTable";
// import { AdminAddFeaturedAgentDialog } from "./AdminAddFeaturedAgentDialog";
// import { revalidatePath } from "next/cache";
// import * as Sentry from "@sentry/nextjs";
// export default async function AdminFeaturedAgentsControl({
// className,
// }: {
// className?: string;
// }) {
// // add featured agent button
// // modal to select agent?
// // modal to select categories?
// // table of featured agents
// // in table
// // remove featured agent button
// // edit featured agent categories button
// // table footer
// // Next page button
// // Previous page button
// // Page number input
// // Page size input
// // Total pages input
// // Go to page button
// const page = 1;
// const pageSize = 10;
// const agents = await getFeaturedAgents(page, pageSize);
// const categories = await getCategories();
// const notFeaturedAgents = await getNotFeaturedAgents();
// return (
// <div className={`flex flex-col gap-4 ${className}`}>
// <div className="mb-4 flex justify-between">
// <h3 className="text-lg font-semibold">Featured Agent Controls</h3>
// <AdminAddFeaturedAgentDialog
// categories={categories.unique_categories}
// agents={notFeaturedAgents.items}
// />
// </div>
// <FeaturedAgentsTable
// agents={agents.items}
// globalActions={[
// {
// component: <Button>Remove</Button>,
// action: async (rows) => {
// "use server";
// return await Sentry.withServerActionInstrumentation(
// "removeFeaturedAgent",
// {},
// async () => {
// const all = rows.map((row) => removeFeaturedAgent(row.id));
// await Promise.all(all);
// revalidatePath("/marketplace");
// },
// );
// },
// },
// ]}
// />
// </div>
// );
// }

View File

@@ -1,36 +0,0 @@
// import { Agent } from "@/lib/marketplace-api";
// import AdminMarketplaceCard from "./AdminMarketplaceCard";
// import { ClipboardX } from "lucide-react";
// export default function AdminMarketplaceAgentList({
// agents,
// className,
// }: {
// agents: Agent[];
// className?: string;
// }) {
// if (agents.length === 0) {
// return (
// <div className={className}>
// <h3 className="text-lg font-semibold">Agents to review</h3>
// <div className="flex flex-col items-center justify-center py-12 text-gray-500">
// <ClipboardX size={48} />
// <p className="mt-4 text-lg font-semibold">No agents to review</p>
// </div>
// </div>
// );
// }
// return (
// <div className={`flex flex-col gap-4 ${className}`}>
// <div>
// <h3 className="text-lg font-semibold">Agents to review</h3>
// </div>
// <div className="flex flex-col gap-4">
// {agents.map((agent) => (
// <AdminMarketplaceCard agent={agent} key={agent.id} />
// ))}
// </div>
// </div>
// );
// }

View File

@@ -1,113 +0,0 @@
// "use client";
// import { Card } from "@/components/ui/card";
// import { Button } from "@/components/ui/button";
// import { Badge } from "@/components/ui/badge";
// import { ScrollArea } from "@/components/ui/scroll-area";
// import { approveAgent, rejectAgent } from "./actions";
// import { Agent } from "@/lib/marketplace-api";
// import Link from "next/link";
// import { useState } from "react";
// import { Input } from "@/components/ui/input";
// function AdminMarketplaceCard({ agent }: { agent: Agent }) {
// const [isApproved, setIsApproved] = useState(false);
// const [isRejected, setIsRejected] = useState(false);
// const [comment, setComment] = useState("");
// const approveAgentWithId = approveAgent.bind(
// null,
// agent.id,
// agent.version,
// comment,
// );
// const rejectAgentWithId = rejectAgent.bind(
// null,
// agent.id,
// agent.version,
// comment,
// );
// const handleApprove = async (e: React.FormEvent) => {
// e.preventDefault();
// await approveAgentWithId();
// setIsApproved(true);
// };
// const handleReject = async (e: React.FormEvent) => {
// e.preventDefault();
// await rejectAgentWithId();
// setIsRejected(true);
// };
// return (
// <>
// {!isApproved && !isRejected && (
// <Card key={agent.id} className="m-3 flex h-[300px] flex-col p-4">
// <div className="mb-2 flex items-start justify-between">
// <Link
// href={`/marketplace/${agent.id}`}
// className="text-lg font-semibold hover:underline"
// >
// {agent.name}
// </Link>
// <Badge variant="outline">v{agent.version}</Badge>
// </div>
// <p className="mb-2 text-sm text-gray-500">by {agent.author}</p>
// <ScrollArea className="flex-grow">
// <p className="mb-2 text-sm text-gray-600">{agent.description}</p>
// <div className="mb-2 flex flex-wrap gap-1">
// {agent.categories.map((category) => (
// <Badge key={category} variant="secondary">
// {category}
// </Badge>
// ))}
// </div>
// <div className="flex flex-wrap gap-1">
// {agent.keywords.map((keyword) => (
// <Badge key={keyword} variant="outline">
// {keyword}
// </Badge>
// ))}
// </div>
// </ScrollArea>
// <div className="mb-2 flex justify-between text-xs text-gray-500">
// <span>
// Created: {new Date(agent.createdAt).toLocaleDateString()}
// </span>
// <span>
// Updated: {new Date(agent.updatedAt).toLocaleDateString()}
// </span>
// </div>
// <div className="mb-4 flex justify-between text-sm">
// <span>👁 {agent.views}</span>
// <span>⬇️ {agent.downloads}</span>
// </div>
// <div className="mt-auto space-y-2">
// <div className="flex justify-end space-x-2">
// <Input
// type="text"
// placeholder="Add a comment (optional)"
// value={comment}
// onChange={(e) => setComment(e.target.value)}
// />
// {!isRejected && (
// <form onSubmit={handleReject}>
// <Button variant="outline" type="submit">
// Reject
// </Button>
// </form>
// )}
// {!isApproved && (
// <form onSubmit={handleApprove}>
// <Button type="submit">Approve</Button>
// </form>
// )}
// </div>
// </div>
// </Card>
// )}
// </>
// );
// }
// export default AdminMarketplaceCard;

View File

@@ -1,114 +0,0 @@
// "use client";
// import { Button } from "@/components/ui/button";
// import { Checkbox } from "@/components/ui/checkbox";
// import { DataTable } from "@/components/ui/data-table";
// import { Agent } from "@/lib/marketplace-api";
// import { ColumnDef } from "@tanstack/react-table";
// import { ArrowUpDown } from "lucide-react";
// import { removeFeaturedAgent } from "./actions";
// import { GlobalActions } from "@/components/ui/data-table";
// export const columns: ColumnDef<Agent>[] = [
// {
// id: "select",
// header: ({ table }) => (
// <Checkbox
// checked={
// table.getIsAllPageRowsSelected() ||
// (table.getIsSomePageRowsSelected() && "indeterminate")
// }
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
// aria-label="Select all"
// />
// ),
// cell: ({ row }) => (
// <Checkbox
// checked={row.getIsSelected()}
// onCheckedChange={(value) => row.toggleSelected(!!value)}
// aria-label="Select row"
// />
// ),
// },
// {
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
// >
// Name
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// accessorKey: "name",
// },
// {
// header: "Description",
// accessorKey: "description",
// },
// {
// header: "Categories",
// accessorKey: "categories",
// },
// {
// header: "Keywords",
// accessorKey: "keywords",
// },
// {
// header: "Downloads",
// accessorKey: "downloads",
// },
// {
// header: "Author",
// accessorKey: "author",
// },
// {
// header: "Version",
// accessorKey: "version",
// },
// {
// header: "actions",
// cell: ({ row }) => {
// const handleRemove = async () => {
// await removeFeaturedAgentWithId();
// };
// // const handleEdit = async () => {
// // console.log("edit");
// // };
// const removeFeaturedAgentWithId = removeFeaturedAgent.bind(
// null,
// row.original.id,
// );
// return (
// <div className="flex justify-end gap-2">
// <Button variant="outline" size="sm" onClick={handleRemove}>
// Remove
// </Button>
// {/* <Button variant="outline" size="sm" onClick={handleEdit}>
// Edit
// </Button> */}
// </div>
// );
// },
// },
// ];
// export default function FeaturedAgentsTable({
// agents,
// globalActions,
// }: {
// agents: Agent[];
// globalActions: GlobalActions<Agent>[];
// }) {
// return (
// <DataTable
// columns={columns}
// data={agents}
// filterPlaceholder="Search agents..."
// filterColumn="name"
// globalActions={globalActions}
// />
// );
// }

View File

@@ -1,165 +0,0 @@
// "use server";
// import MarketplaceAPI from "@/lib/marketplace-api";
// import ServerSideMarketplaceAPI from "@/lib/marketplace-api/server-client";
// import { revalidatePath } from "next/cache";
// import * as Sentry from "@sentry/nextjs";
// import { redirect } from "next/navigation";
// export async function checkAuth() {
// const supabase = getServerSupabase();
// if (!supabase) {
// console.error("No supabase client");
// redirect("/login");
// }
// const { data, error } = await supabase.auth.getUser();
// if (error || !data?.user) {
// redirect("/login");
// }
// }
// export async function approveAgent(
// agentId: string,
// version: number,
// comment: string,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "approveAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.approveAgentSubmission(agentId, version, comment);
// console.debug(`Approving agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function rejectAgent(
// agentId: string,
// version: number,
// comment: string,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "rejectAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.rejectAgentSubmission(agentId, version, comment);
// console.debug(`Rejecting agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function getReviewableAgents() {
// return await Sentry.withServerActionInstrumentation(
// "getReviewableAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// return api.getAgentSubmissions();
// },
// );
// }
// export async function getFeaturedAgents(
// page: number = 1,
// pageSize: number = 10,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "getFeaturedAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const featured = await api.getFeaturedAgents(page, pageSize);
// console.debug(`Getting featured agents ${featured.items.length}`);
// return featured;
// },
// );
// }
// export async function getFeaturedAgent(agentId: string) {
// return await Sentry.withServerActionInstrumentation(
// "getFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const featured = await api.getFeaturedAgent(agentId);
// console.debug(`Getting featured agent ${featured.agentId}`);
// return featured;
// },
// );
// }
// export async function addFeaturedAgent(
// agentId: string,
// categories: string[] = ["featured"],
// ) {
// return await Sentry.withServerActionInstrumentation(
// "addFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.addFeaturedAgent(agentId, categories);
// console.debug(`Adding featured agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function removeFeaturedAgent(
// agentId: string,
// categories: string[] = ["featured"],
// ) {
// return await Sentry.withServerActionInstrumentation(
// "removeFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.removeFeaturedAgent(agentId, categories);
// console.debug(`Removing featured agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function getCategories() {
// return await Sentry.withServerActionInstrumentation(
// "getCategories",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const categories = await api.getCategories();
// console.debug(
// `Getting categories ${categories.unique_categories.length}`,
// );
// return categories;
// },
// );
// }
// export async function getNotFeaturedAgents(
// page: number = 1,
// pageSize: number = 100,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "getNotFeaturedAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const agents = await api.getNotFeaturedAgents(page, pageSize);
// console.debug(`Getting not featured agents ${agents.items.length}`);
// return agents;
// },
// );
// }

View File

@@ -0,0 +1,99 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
StoreSubmission,
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
import { PaginationControls } from "../../ui/pagination-controls";
import { getAdminListingsWithVersions } from "@/app/admin/marketplace/actions";
import { ExpandableRow } from "./expandable-row";
import { SearchAndFilterAdminMarketplace } from "./search-filter-form";
// Helper function to get the latest version by version number
const getLatestVersionByNumber = (
versions: StoreSubmission[],
): StoreSubmission | null => {
if (!versions || versions.length === 0) return null;
return versions.reduce(
(latest, current) =>
(current.version ?? 0) > (latest.version ?? 1) ? current : latest,
versions[0],
);
};
export async function AdminAgentsDataTable({
initialPage = 1,
initialStatus,
initialSearch,
}: {
initialPage?: number;
initialStatus?: SubmissionStatus;
initialSearch?: string;
}) {
// Server-side data fetching
const { listings, pagination } = await getAdminListingsWithVersions(
initialStatus,
initialSearch,
initialPage,
10,
);
return (
<div className="space-y-4">
<SearchAndFilterAdminMarketplace
initialStatus={initialStatus}
initialSearch={initialSearch}
/>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Creator</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listings.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center">
No submissions found
</TableCell>
</TableRow>
) : (
listings.map((listing) => {
const latestVersion = getLatestVersionByNumber(
listing.versions,
);
return (
<ExpandableRow
key={listing.listing_id}
listing={listing}
latestVersion={latestVersion}
/>
);
})
)}
</TableBody>
</Table>
</div>
<PaginationControls
currentPage={initialPage}
totalPages={pagination.total_pages}
/>
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle, XCircle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { StoreSubmission } from "@/lib/autogpt-server-api/types";
import { useRouter } from "next/navigation";
import { approveAgent, rejectAgent } from "@/app/admin/marketplace/actions";
export function ApproveRejectButtons({
version,
}: {
version: StoreSubmission;
}) {
const router = useRouter();
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
const [isRejectDialogOpen, setIsRejectDialogOpen] = useState(false);
const handleApproveSubmit = async (formData: FormData) => {
setIsApproveDialogOpen(false);
try {
await approveAgent(formData);
router.refresh(); // Refresh the current route
} catch (error) {
console.error("Error approving agent:", error);
}
};
const handleRejectSubmit = async (formData: FormData) => {
setIsRejectDialogOpen(false);
try {
await rejectAgent(formData);
router.refresh(); // Refresh the current route
} catch (error) {
console.error("Error rejecting agent:", error);
}
};
return (
<>
<Button
size="sm"
variant="outline"
className="text-green-600 hover:bg-green-50 hover:text-green-700"
onClick={(e) => {
e.stopPropagation();
setIsApproveDialogOpen(true);
}}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
setIsRejectDialogOpen(true);
}}
>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
{/* Approve Dialog */}
<Dialog open={isApproveDialogOpen} onOpenChange={setIsApproveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve Agent</DialogTitle>
<DialogDescription>
Are you sure you want to approve this agent? This will make it
available in the marketplace.
</DialogDescription>
</DialogHeader>
<form action={handleApproveSubmit}>
<input
type="hidden"
name="id"
value={version.store_listing_version_id || ""}
/>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="comments">Comments (Optional)</Label>
<Textarea
id="comments"
name="comments"
placeholder="Add any comments for the agent creator"
defaultValue="Meets all requirements"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsApproveDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit">Approve</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Reject Dialog */}
<Dialog open={isRejectDialogOpen} onOpenChange={setIsRejectDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Agent</DialogTitle>
<DialogDescription>
Please provide feedback on why this agent is being rejected.
</DialogDescription>
</DialogHeader>
<form action={handleRejectSubmit}>
<input
type="hidden"
name="id"
value={version.store_listing_version_id || ""}
/>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="comments">Comments for Creator</Label>
<Textarea
id="comments"
name="comments"
placeholder="Provide feedback for the agent creator"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="internal_comments">Internal Comments</Label>
<Textarea
id="internal_comments"
name="internal_comments"
placeholder="Add any internal notes (not visible to creator)"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsRejectDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" variant="destructive">
Reject
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import {
TableRow,
TableCell,
Table,
TableHeader,
TableHead,
TableBody,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import {
type StoreListingWithVersions,
type StoreSubmission,
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
import { ApproveRejectButtons } from "./approve-reject-buttons";
// Moved the getStatusBadge function into the client component
const getStatusBadge = (status: SubmissionStatus) => {
switch (status) {
case SubmissionStatus.PENDING:
return <Badge className="bg-amber-500">Pending</Badge>;
case SubmissionStatus.APPROVED:
return <Badge className="bg-green-500">Approved</Badge>;
case SubmissionStatus.REJECTED:
return <Badge className="bg-red-500">Rejected</Badge>;
default:
return <Badge className="bg-gray-500">Draft</Badge>;
}
};
export function ExpandableRow({
listing,
latestVersion,
}: {
listing: StoreListingWithVersions;
latestVersion: StoreSubmission | null;
}) {
const [expanded, setExpanded] = useState(false);
return (
<>
<TableRow className="cursor-pointer hover:bg-muted/50">
<TableCell onClick={() => setExpanded(!expanded)}>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</TableCell>
<TableCell
className="font-medium"
onClick={() => setExpanded(!expanded)}
>
{latestVersion?.name || "Unnamed Agent"}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{listing.creator_email || "Unknown"}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{latestVersion?.sub_heading || "No description"}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{latestVersion?.status && getStatusBadge(latestVersion.status)}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{latestVersion?.date_submitted
? formatDistanceToNow(new Date(latestVersion.date_submitted), {
addSuffix: true,
})
: "Unknown"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline">
<ExternalLink className="mr-2 h-4 w-4" />
Builder
</Button>
{latestVersion?.status === SubmissionStatus.PENDING && (
<ApproveRejectButtons version={latestVersion} />
)}
</div>
</TableCell>
</TableRow>
{/* Expanded version history */}
{expanded && (
<TableRow>
<TableCell colSpan={7} className="border-t-0 p-0">
<div className="bg-muted/30 px-4 py-3">
<h4 className="mb-2 text-sm font-semibold">Version History</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
{/* <TableHead>Changes</TableHead> */}
<TableHead>Submitted</TableHead>
<TableHead>Reviewed</TableHead>
<TableHead>External Comments</TableHead>
<TableHead>Internal Comments</TableHead>
<TableHead>Name</TableHead>
<TableHead>Sub Heading</TableHead>
<TableHead>Description</TableHead>
{/* <TableHead>Categories</TableHead> */}
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listing.versions
.sort((a, b) => (b.version ?? 1) - (a.version ?? 0))
.map((version) => (
<TableRow key={version.store_listing_version_id}>
<TableCell>
v{version.version || "?"}
{version.store_listing_version_id ===
listing.active_version_id && (
<Badge className="ml-2 bg-blue-500">Active</Badge>
)}
</TableCell>
<TableCell>{getStatusBadge(version.status)}</TableCell>
{/* <TableCell>
{version.changes_summary || "No summary"}
</TableCell> */}
<TableCell>
{version.date_submitted
? formatDistanceToNow(
new Date(version.date_submitted),
{ addSuffix: true },
)
: "Unknown"}
</TableCell>
<TableCell>
{version.reviewed_at
? formatDistanceToNow(
new Date(version.reviewed_at),
{
addSuffix: true,
},
)
: "Not reviewed"}
</TableCell>
<TableCell className="max-w-xs truncate">
{version.review_comments ? (
<div
className="truncate"
title={version.review_comments}
>
{version.review_comments}
</div>
) : (
<span className="text-gray-400">
No external comments
</span>
)}
</TableCell>
<TableCell className="max-w-xs truncate">
{version.internal_comments ? (
<div
className="truncate text-pink-600"
title={version.internal_comments}
>
{version.internal_comments}
</div>
) : (
<span className="text-gray-400">
No internal comments
</span>
)}
</TableCell>
<TableCell>{version.name}</TableCell>
<TableCell>{version.sub_heading}</TableCell>
<TableCell>{version.description}</TableCell>
{/* <TableCell>{version.categories.join(", ")}</TableCell> */}
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
(window.location.href = `/admin/agents/${version.store_listing_version_id}`)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
Builder
</Button>
{version.status === SubmissionStatus.PENDING && (
<ApproveRejectButtons version={version} />
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
)}
</>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SubmissionStatus } from "@/lib/autogpt-server-api/types";
export function SearchAndFilterAdminMarketplace({
initialStatus,
initialSearch,
}: {
initialStatus?: SubmissionStatus;
initialSearch?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Initialize state from URL parameters
const [searchQuery, setSearchQuery] = useState(initialSearch || "");
const [selectedStatus, setSelectedStatus] = useState<string>(
searchParams.get("status") || "ALL",
);
// Update local state when URL parameters change
useEffect(() => {
const status = searchParams.get("status");
setSelectedStatus(status || "ALL");
setSearchQuery(searchParams.get("search") || "");
}, [searchParams]);
const handleSearch = () => {
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set("search", searchQuery);
} else {
params.delete("search");
}
if (selectedStatus !== "ALL") {
params.set("status", selectedStatus);
} else {
params.delete("status");
}
params.set("page", "1"); // Reset to first page on new search
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-2">
<Input
placeholder="Search agents by Name, Creator, or Description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<Select
value={selectedStatus}
onValueChange={(value) => {
setSelectedStatus(value);
const params = new URLSearchParams(searchParams.toString());
if (value === "ALL") {
params.delete("status");
} else {
params.set("status", value);
}
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={SubmissionStatus.PENDING}>Pending</SelectItem>
<SelectItem value={SubmissionStatus.APPROVED}>Approved</SelectItem>
<SelectItem value={SubmissionStatus.REJECTED}>Rejected</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
export function PaginationControls({
currentPage,
totalPages,
}: {
currentPage: number;
totalPages: number;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
return `${pathname}?${params.toString()}`;
};
const handlePageChange = (page: number) => {
router.push(createPageUrl(page));
};
return (
<div className="mt-4 flex items-center justify-center space-x-2">
<Button
variant="outline"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
Previous
</Button>
<span className="text-sm">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Next
</Button>
</div>
);
}

View File

@@ -39,6 +39,7 @@ import {
ScheduleID,
StoreAgentDetails,
StoreAgentsResponse,
StoreListingsWithVersionsResponse,
StoreReview,
StoreReviewCreate,
StoreSubmission,
@@ -50,6 +51,8 @@ import {
OttoQuery,
OttoResponse,
UserOnboarding,
ReviewSubmissionRequest,
SubmissionStatus,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
import getServerSupabase from "../supabase/getServerSupabase";
@@ -509,6 +512,30 @@ export default class BackendAPI {
return this._get(url);
}
/////////////////////////////////////////
/////////// Admin API ///////////////////
/////////////////////////////////////////
getAdminListingsWithVersions(params?: {
status?: SubmissionStatus;
search?: string;
page?: number;
page_size?: number;
}): Promise<StoreListingsWithVersionsResponse> {
return this._get("/store/admin/listings", params);
}
reviewSubmissionAdmin(
storeListingVersionId: string,
review: ReviewSubmissionRequest,
): Promise<StoreSubmission> {
return this._request(
"POST",
`/store/admin/submissions/${storeListingVersionId}/review`,
review,
);
}
/////////////////////////////////////////
/////////// V2 LIBRARY API //////////////
/////////////////////////////////////////

View File

@@ -1,4 +1,9 @@
export type SubmissionStatus = "DRAFT" | "PENDING" | "APPROVED" | "REJECTED";
export enum SubmissionStatus {
DRAFT = "DRAFT",
PENDING = "PENDING",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
}
export type ReviewSubmissionRequest = {
store_listing_version_id: string;
is_approved: boolean;
@@ -612,6 +617,7 @@ export type StoreSubmission = {
rating: number;
slug: string;
store_listing_version_id?: string;
version?: number; // Actual version number from the database
// Review information
reviewer_id?: string;
@@ -619,9 +625,6 @@ export type StoreSubmission = {
internal_comments?: string; // Admin-only comments
reviewed_at?: string;
changes_summary?: string;
// Approval status
is_approved?: boolean;
};
export type StoreSubmissionsResponse = {
@@ -796,6 +799,23 @@ export interface OttoQuery {
graph_id?: string;
}
export interface StoreListingWithVersions {
listing_id: string;
slug: string;
agent_id: string;
agent_version: number;
active_version_id: string | null;
has_approved_version: boolean;
creator_email: string | null;
latest_version: StoreSubmission | null;
versions: StoreSubmission[];
}
export interface StoreListingsWithVersionsResponse {
listings: StoreListingWithVersions[];
pagination: Pagination;
}
// Admin API Types
export type AdminSubmissionsRequest = {
status?: SubmissionStatus;