mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
// );
|
||||
// };
|
||||
@@ -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>
|
||||
// );
|
||||
// }
|
||||
@@ -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>
|
||||
// );
|
||||
// }
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
@@ -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;
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 //////////////
|
||||
/////////////////////////////////////////
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user