mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-05 20:35:10 -05:00
Compare commits
1 Commits
ntindle/wa
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7a7067ec |
@@ -1,251 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
import autogpt_libs.auth
|
|
||||||
import fastapi
|
|
||||||
import fastapi.responses
|
|
||||||
|
|
||||||
import backend.api.features.store.db as store_db
|
|
||||||
import backend.api.features.store.model as store_model
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = fastapi.APIRouter(
|
|
||||||
prefix="/admin/waitlist",
|
|
||||||
tags=["store", "admin", "waitlist"],
|
|
||||||
dependencies=[fastapi.Security(autogpt_libs.auth.requires_admin_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
summary="Create Waitlist",
|
|
||||||
response_model=store_model.WaitlistAdminResponse,
|
|
||||||
)
|
|
||||||
async def create_waitlist(
|
|
||||||
request: store_model.WaitlistCreateRequest,
|
|
||||||
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create a new waitlist (admin only).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Waitlist creation details
|
|
||||||
user_id: Authenticated admin user creating the waitlist
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WaitlistAdminResponse with the created waitlist details
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
waitlist = await store_db.create_waitlist_admin(
|
|
||||||
admin_user_id=user_id,
|
|
||||||
data=request,
|
|
||||||
)
|
|
||||||
return waitlist
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error creating waitlist: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while creating the waitlist"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
summary="List All Waitlists",
|
|
||||||
response_model=store_model.WaitlistAdminListResponse,
|
|
||||||
)
|
|
||||||
async def list_waitlists():
|
|
||||||
"""
|
|
||||||
Get all waitlists with admin details (admin only).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WaitlistAdminListResponse with all waitlists
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await store_db.get_waitlists_admin()
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error listing waitlists: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while fetching waitlists"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{waitlist_id}",
|
|
||||||
summary="Get Waitlist Details",
|
|
||||||
response_model=store_model.WaitlistAdminResponse,
|
|
||||||
)
|
|
||||||
async def get_waitlist(
|
|
||||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get a single waitlist with admin details (admin only).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
waitlist_id: ID of the waitlist to retrieve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WaitlistAdminResponse with waitlist details
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await store_db.get_waitlist_admin(waitlist_id)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Waitlist not found: %s", waitlist_id)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"detail": "Waitlist not found"},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error fetching waitlist: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while fetching the waitlist"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/{waitlist_id}",
|
|
||||||
summary="Update Waitlist",
|
|
||||||
response_model=store_model.WaitlistAdminResponse,
|
|
||||||
)
|
|
||||||
async def update_waitlist(
|
|
||||||
request: store_model.WaitlistUpdateRequest,
|
|
||||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update a waitlist (admin only).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
waitlist_id: ID of the waitlist to update
|
|
||||||
request: Fields to update
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WaitlistAdminResponse with updated waitlist details
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await store_db.update_waitlist_admin(waitlist_id, request)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Waitlist not found for update: %s", waitlist_id)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"detail": "Waitlist not found"},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error updating waitlist: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while updating the waitlist"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{waitlist_id}",
|
|
||||||
summary="Delete Waitlist",
|
|
||||||
)
|
|
||||||
async def delete_waitlist(
|
|
||||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Soft delete a waitlist (admin only).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
waitlist_id: ID of the waitlist to delete
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await store_db.delete_waitlist_admin(waitlist_id)
|
|
||||||
return {"message": "Waitlist deleted successfully"}
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Waitlist not found for deletion: {waitlist_id}")
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"detail": "Waitlist not found"},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error deleting waitlist: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while deleting the waitlist"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{waitlist_id}/signups",
|
|
||||||
summary="Get Waitlist Signups",
|
|
||||||
response_model=store_model.WaitlistSignupListResponse,
|
|
||||||
)
|
|
||||||
async def get_waitlist_signups(
|
|
||||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get all signups for a waitlist (admin only).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
waitlist_id: ID of the waitlist
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WaitlistSignupListResponse with all signups
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await store_db.get_waitlist_signups_admin(waitlist_id)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Waitlist not found for signups: %s", waitlist_id)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"detail": "Waitlist not found"},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error fetching waitlist signups: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while fetching waitlist signups"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{waitlist_id}/link",
|
|
||||||
summary="Link Waitlist to Store Listing",
|
|
||||||
response_model=store_model.WaitlistAdminResponse,
|
|
||||||
)
|
|
||||||
async def link_waitlist_to_listing(
|
|
||||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
|
||||||
store_listing_id: str = fastapi.Body(
|
|
||||||
..., embed=True, description="The ID of the store listing"
|
|
||||||
),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Link a waitlist to a store listing (admin only).
|
|
||||||
|
|
||||||
When the linked store listing is approved/published, waitlist users
|
|
||||||
will be automatically notified.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
waitlist_id: ID of the waitlist
|
|
||||||
store_listing_id: ID of the store listing to link
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WaitlistAdminResponse with updated waitlist details
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await store_db.link_waitlist_to_listing_admin(
|
|
||||||
waitlist_id, store_listing_id
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
"Link failed - waitlist or listing not found: %s, %s",
|
|
||||||
waitlist_id,
|
|
||||||
store_listing_id,
|
|
||||||
)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"detail": "Waitlist or store listing not found"},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Error linking waitlist to listing: %s", e)
|
|
||||||
return fastapi.responses.JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "An error occurred while linking the waitlist"},
|
|
||||||
)
|
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.api.features.store.exceptions import AgentNotFoundError
|
from backend.api.features.store.exceptions import AgentNotFoundError
|
||||||
@@ -27,6 +29,23 @@ from .models import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomizeAgentInput(BaseModel):
|
||||||
|
"""Input parameters for the customize_agent tool."""
|
||||||
|
|
||||||
|
agent_id: str = ""
|
||||||
|
modifications: str = ""
|
||||||
|
context: str = ""
|
||||||
|
save: bool = True
|
||||||
|
|
||||||
|
@field_validator("agent_id", "modifications", "context", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_strings(cls, v: Any) -> str:
|
||||||
|
"""Strip whitespace from string fields."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip()
|
||||||
|
return v if v is not None else ""
|
||||||
|
|
||||||
|
|
||||||
class CustomizeAgentTool(BaseTool):
|
class CustomizeAgentTool(BaseTool):
|
||||||
"""Tool for customizing marketplace/template agents using natural language."""
|
"""Tool for customizing marketplace/template agents using natural language."""
|
||||||
|
|
||||||
@@ -92,7 +111,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
session: ChatSession,
|
session: ChatSession,
|
||||||
**kwargs,
|
**kwargs: Any,
|
||||||
) -> ToolResponseBase:
|
) -> ToolResponseBase:
|
||||||
"""Execute the customize_agent tool.
|
"""Execute the customize_agent tool.
|
||||||
|
|
||||||
@@ -102,20 +121,17 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
3. Call customize_template with the modification request
|
3. Call customize_template with the modification request
|
||||||
4. Preview or save based on the save parameter
|
4. Preview or save based on the save parameter
|
||||||
"""
|
"""
|
||||||
agent_id = kwargs.get("agent_id", "").strip()
|
params = CustomizeAgentInput(**kwargs)
|
||||||
modifications = kwargs.get("modifications", "").strip()
|
|
||||||
context = kwargs.get("context", "")
|
|
||||||
save = kwargs.get("save", True)
|
|
||||||
session_id = session.session_id if session else None
|
session_id = session.session_id if session else None
|
||||||
|
|
||||||
if not agent_id:
|
if not params.agent_id:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Please provide the marketplace agent ID (e.g., 'creator/agent-name').",
|
message="Please provide the marketplace agent ID (e.g., 'creator/agent-name').",
|
||||||
error="missing_agent_id",
|
error="missing_agent_id",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not modifications:
|
if not params.modifications:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Please describe how you want to customize this agent.",
|
message="Please describe how you want to customize this agent.",
|
||||||
error="missing_modifications",
|
error="missing_modifications",
|
||||||
@@ -123,11 +139,11 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Parse agent_id in format "creator/slug"
|
# Parse agent_id in format "creator/slug"
|
||||||
parts = [p.strip() for p in agent_id.split("/")]
|
parts = params.agent_id.split("/")
|
||||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
f"Invalid agent ID format: '{agent_id}'. "
|
f"Invalid agent ID format: '{params.agent_id}'. "
|
||||||
"Expected format is 'creator/agent-name' "
|
"Expected format is 'creator/agent-name' "
|
||||||
"(e.g., 'autogpt/newsletter-writer')."
|
"(e.g., 'autogpt/newsletter-writer')."
|
||||||
),
|
),
|
||||||
@@ -145,14 +161,14 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
except AgentNotFoundError:
|
except AgentNotFoundError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
f"Could not find marketplace agent '{agent_id}'. "
|
f"Could not find marketplace agent '{params.agent_id}'. "
|
||||||
"Please check the agent ID and try again."
|
"Please check the agent ID and try again."
|
||||||
),
|
),
|
||||||
error="agent_not_found",
|
error="agent_not_found",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching marketplace agent {agent_id}: {e}")
|
logger.error(f"Error fetching marketplace agent {params.agent_id}: {e}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Failed to fetch the marketplace agent. Please try again.",
|
message="Failed to fetch the marketplace agent. Please try again.",
|
||||||
error="fetch_error",
|
error="fetch_error",
|
||||||
@@ -162,7 +178,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
if not agent_details.store_listing_version_id:
|
if not agent_details.store_listing_version_id:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
f"The agent '{agent_id}' does not have an available version. "
|
f"The agent '{params.agent_id}' does not have an available version. "
|
||||||
"Please try a different agent."
|
"Please try a different agent."
|
||||||
),
|
),
|
||||||
error="no_version_available",
|
error="no_version_available",
|
||||||
@@ -174,7 +190,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
graph = await store_db.get_agent(agent_details.store_listing_version_id)
|
graph = await store_db.get_agent(agent_details.store_listing_version_id)
|
||||||
template_agent = graph_to_json(graph)
|
template_agent = graph_to_json(graph)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching agent graph for {agent_id}: {e}")
|
logger.error(f"Error fetching agent graph for {params.agent_id}: {e}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Failed to fetch the agent configuration. Please try again.",
|
message="Failed to fetch the agent configuration. Please try again.",
|
||||||
error="graph_fetch_error",
|
error="graph_fetch_error",
|
||||||
@@ -185,8 +201,8 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
try:
|
try:
|
||||||
result = await customize_template(
|
result = await customize_template(
|
||||||
template_agent=template_agent,
|
template_agent=template_agent,
|
||||||
modification_request=modifications,
|
modification_request=params.modifications,
|
||||||
context=context,
|
context=params.context,
|
||||||
)
|
)
|
||||||
except AgentGeneratorNotConfiguredError:
|
except AgentGeneratorNotConfiguredError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
@@ -198,7 +214,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calling customize_template for {agent_id}: {e}")
|
logger.error(f"Error calling customize_template for {params.agent_id}: {e}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
"Failed to customize the agent due to a service error. "
|
"Failed to customize the agent due to a service error. "
|
||||||
@@ -219,55 +235,25 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle error response
|
# Handle response using match/case for cleaner pattern matching
|
||||||
if isinstance(result, dict) and result.get("type") == "error":
|
return await self._handle_customization_result(
|
||||||
error_msg = result.get("error", "Unknown error")
|
result=result,
|
||||||
error_type = result.get("error_type", "unknown")
|
params=params,
|
||||||
user_message = get_user_message_for_error(
|
agent_details=agent_details,
|
||||||
error_type,
|
user_id=user_id,
|
||||||
operation="customize the agent",
|
session_id=session_id,
|
||||||
llm_parse_message=(
|
)
|
||||||
"The AI had trouble customizing the agent. "
|
|
||||||
"Please try again or simplify your request."
|
|
||||||
),
|
|
||||||
validation_message=(
|
|
||||||
"The customized agent failed validation. "
|
|
||||||
"Please try rephrasing your request."
|
|
||||||
),
|
|
||||||
error_details=error_msg,
|
|
||||||
)
|
|
||||||
return ErrorResponse(
|
|
||||||
message=user_message,
|
|
||||||
error=f"customization_failed:{error_type}",
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle clarifying questions
|
async def _handle_customization_result(
|
||||||
if isinstance(result, dict) and result.get("type") == "clarifying_questions":
|
self,
|
||||||
questions = result.get("questions") or []
|
result: dict[str, Any],
|
||||||
if not isinstance(questions, list):
|
params: CustomizeAgentInput,
|
||||||
logger.error(
|
agent_details: Any,
|
||||||
f"Unexpected clarifying questions format: {type(questions)}"
|
user_id: str | None,
|
||||||
)
|
session_id: str | None,
|
||||||
questions = []
|
) -> ToolResponseBase:
|
||||||
return ClarificationNeededResponse(
|
"""Handle the result from customize_template using pattern matching."""
|
||||||
message=(
|
# Ensure result is a dict
|
||||||
"I need some more information to customize this agent. "
|
|
||||||
"Please answer the following questions:"
|
|
||||||
),
|
|
||||||
questions=[
|
|
||||||
ClarifyingQuestion(
|
|
||||||
question=q.get("question", ""),
|
|
||||||
keyword=q.get("keyword", ""),
|
|
||||||
example=q.get("example"),
|
|
||||||
)
|
|
||||||
for q in questions
|
|
||||||
if isinstance(q, dict)
|
|
||||||
],
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Result should be the customized agent JSON
|
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
logger.error(f"Unexpected customize_template response type: {type(result)}")
|
logger.error(f"Unexpected customize_template response type: {type(result)}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
@@ -276,8 +262,77 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
customized_agent = result
|
result_type = result.get("type")
|
||||||
|
|
||||||
|
match result_type:
|
||||||
|
case "error":
|
||||||
|
error_msg = result.get("error", "Unknown error")
|
||||||
|
error_type = result.get("error_type", "unknown")
|
||||||
|
user_message = get_user_message_for_error(
|
||||||
|
error_type,
|
||||||
|
operation="customize the agent",
|
||||||
|
llm_parse_message=(
|
||||||
|
"The AI had trouble customizing the agent. "
|
||||||
|
"Please try again or simplify your request."
|
||||||
|
),
|
||||||
|
validation_message=(
|
||||||
|
"The customized agent failed validation. "
|
||||||
|
"Please try rephrasing your request."
|
||||||
|
),
|
||||||
|
error_details=error_msg,
|
||||||
|
)
|
||||||
|
return ErrorResponse(
|
||||||
|
message=user_message,
|
||||||
|
error=f"customization_failed:{error_type}",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
case "clarifying_questions":
|
||||||
|
questions_data = result.get("questions") or []
|
||||||
|
if not isinstance(questions_data, list):
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected clarifying questions format: {type(questions_data)}"
|
||||||
|
)
|
||||||
|
questions_data = []
|
||||||
|
|
||||||
|
questions = [
|
||||||
|
ClarifyingQuestion(
|
||||||
|
question=q.get("question", "") if isinstance(q, dict) else "",
|
||||||
|
keyword=q.get("keyword", "") if isinstance(q, dict) else "",
|
||||||
|
example=q.get("example") if isinstance(q, dict) else None,
|
||||||
|
)
|
||||||
|
for q in questions_data
|
||||||
|
if isinstance(q, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
return ClarificationNeededResponse(
|
||||||
|
message=(
|
||||||
|
"I need some more information to customize this agent. "
|
||||||
|
"Please answer the following questions:"
|
||||||
|
),
|
||||||
|
questions=questions,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
# Default case: result is the customized agent JSON
|
||||||
|
return await self._save_or_preview_agent(
|
||||||
|
customized_agent=result,
|
||||||
|
params=params,
|
||||||
|
agent_details=agent_details,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _save_or_preview_agent(
|
||||||
|
self,
|
||||||
|
customized_agent: dict[str, Any],
|
||||||
|
params: CustomizeAgentInput,
|
||||||
|
agent_details: Any,
|
||||||
|
user_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
) -> ToolResponseBase:
|
||||||
|
"""Save or preview the customized agent based on params.save."""
|
||||||
agent_name = customized_agent.get(
|
agent_name = customized_agent.get(
|
||||||
"name", f"Customized {agent_details.agent_name}"
|
"name", f"Customized {agent_details.agent_name}"
|
||||||
)
|
)
|
||||||
@@ -287,7 +342,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
node_count = len(nodes) if isinstance(nodes, list) else 0
|
node_count = len(nodes) if isinstance(nodes, list) else 0
|
||||||
link_count = len(links) if isinstance(links, list) else 0
|
link_count = len(links) if isinstance(links, list) else 0
|
||||||
|
|
||||||
if not save:
|
if not params.save:
|
||||||
return AgentPreviewResponse(
|
return AgentPreviewResponse(
|
||||||
message=(
|
message=(
|
||||||
f"I've customized the agent '{agent_details.agent_name}'. "
|
f"I've customized the agent '{agent_details.agent_name}'. "
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ from backend.api.features.library import model as library_model
|
|||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data import graph as graph_db
|
from backend.data import graph as graph_db
|
||||||
from backend.data.graph import GraphModel
|
from backend.data.graph import GraphModel
|
||||||
from backend.data.model import (
|
from backend.data.model import Credentials, CredentialsFieldInfo, CredentialsMetaInput
|
||||||
CredentialsFieldInfo,
|
|
||||||
CredentialsMetaInput,
|
|
||||||
HostScopedCredentials,
|
|
||||||
OAuth2Credentials,
|
|
||||||
)
|
|
||||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
|
|
||||||
@@ -278,14 +273,7 @@ async def match_user_credentials_to_graph(
|
|||||||
for cred in available_creds
|
for cred in available_creds
|
||||||
if cred.provider in credential_requirements.provider
|
if cred.provider in credential_requirements.provider
|
||||||
and cred.type in credential_requirements.supported_types
|
and cred.type in credential_requirements.supported_types
|
||||||
and (
|
and _credential_has_required_scopes(cred, credential_requirements)
|
||||||
cred.type != "oauth2"
|
|
||||||
or _credential_has_required_scopes(cred, credential_requirements)
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
cred.type != "host_scoped"
|
|
||||||
or _credential_is_for_host(cred, credential_requirements)
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -330,10 +318,19 @@ async def match_user_credentials_to_graph(
|
|||||||
|
|
||||||
|
|
||||||
def _credential_has_required_scopes(
|
def _credential_has_required_scopes(
|
||||||
credential: OAuth2Credentials,
|
credential: Credentials,
|
||||||
requirements: CredentialsFieldInfo,
|
requirements: CredentialsFieldInfo,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if an OAuth2 credential has all the scopes required by the input."""
|
"""
|
||||||
|
Check if a credential has all the scopes required by the block.
|
||||||
|
|
||||||
|
For OAuth2 credentials, verifies that the credential's scopes are a superset
|
||||||
|
of the required scopes. For other credential types, returns True (no scope check).
|
||||||
|
"""
|
||||||
|
# Only OAuth2 credentials have scopes to check
|
||||||
|
if credential.type != "oauth2":
|
||||||
|
return True
|
||||||
|
|
||||||
# If no scopes are required, any credential matches
|
# If no scopes are required, any credential matches
|
||||||
if not requirements.required_scopes:
|
if not requirements.required_scopes:
|
||||||
return True
|
return True
|
||||||
@@ -342,22 +339,6 @@ def _credential_has_required_scopes(
|
|||||||
return set(credential.scopes).issuperset(requirements.required_scopes)
|
return set(credential.scopes).issuperset(requirements.required_scopes)
|
||||||
|
|
||||||
|
|
||||||
def _credential_is_for_host(
|
|
||||||
credential: HostScopedCredentials,
|
|
||||||
requirements: CredentialsFieldInfo,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a host-scoped credential matches the host required by the input."""
|
|
||||||
# We need to know the host to match host-scoped credentials to.
|
|
||||||
# Graph.aggregate_credentials_inputs() adds the node's set URL value (if any)
|
|
||||||
# to discriminator_values. No discriminator_values -> no host to match against.
|
|
||||||
if not requirements.discriminator_values:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check that credential host matches required host.
|
|
||||||
# Host-scoped credential inputs are grouped by host, so any item from the set works.
|
|
||||||
return credential.matches_url(list(requirements.discriminator_values)[0])
|
|
||||||
|
|
||||||
|
|
||||||
async def check_user_has_required_credentials(
|
async def check_user_has_required_credentials(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
required_credentials: list[CredentialsMetaInput],
|
required_credentials: list[CredentialsMetaInput],
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from backend.data.notifications import (
|
|||||||
AgentApprovalData,
|
AgentApprovalData,
|
||||||
AgentRejectionData,
|
AgentRejectionData,
|
||||||
NotificationEventModel,
|
NotificationEventModel,
|
||||||
WaitlistLaunchData,
|
|
||||||
)
|
)
|
||||||
from backend.notifications.notifications import queue_notification_async
|
from backend.notifications.notifications import queue_notification_async
|
||||||
from backend.util.exceptions import DatabaseError
|
from backend.util.exceptions import DatabaseError
|
||||||
@@ -1714,29 +1713,6 @@ async def review_store_submission(
|
|||||||
# Don't fail the review process if email sending fails
|
# Don't fail the review process if email sending fails
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Notify waitlist users if this is an approval and has a linked waitlist
|
|
||||||
if is_approved and submission.StoreListing:
|
|
||||||
try:
|
|
||||||
frontend_base_url = (
|
|
||||||
settings.config.frontend_base_url
|
|
||||||
or settings.config.platform_base_url
|
|
||||||
)
|
|
||||||
store_agent = (
|
|
||||||
await prisma.models.StoreAgent.prisma().find_first_or_raise(
|
|
||||||
where={"storeListingVersionId": submission.id}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
creator_username = store_agent.creator_username or "unknown"
|
|
||||||
store_url = f"{frontend_base_url}/marketplace/agent/{creator_username}/{store_agent.slug}"
|
|
||||||
await notify_waitlist_users_on_launch(
|
|
||||||
store_listing_id=submission.StoreListing.id,
|
|
||||||
agent_name=submission.name,
|
|
||||||
store_url=store_url,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to notify waitlist users on agent approval: {e}")
|
|
||||||
# Don't fail the approval process
|
|
||||||
|
|
||||||
# Convert to Pydantic model for consistency
|
# Convert to Pydantic model for consistency
|
||||||
return store_model.StoreSubmission(
|
return store_model.StoreSubmission(
|
||||||
listing_id=(submission.StoreListing.id if submission.StoreListing else ""),
|
listing_id=(submission.StoreListing.id if submission.StoreListing else ""),
|
||||||
@@ -1984,552 +1960,3 @@ async def get_agent_as_admin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
|
|
||||||
def _waitlist_to_store_entry(
|
|
||||||
waitlist: prisma.models.WaitlistEntry,
|
|
||||||
) -> store_model.StoreWaitlistEntry:
|
|
||||||
"""Convert a WaitlistEntry to StoreWaitlistEntry for public display."""
|
|
||||||
return store_model.StoreWaitlistEntry(
|
|
||||||
waitlistId=waitlist.id,
|
|
||||||
slug=waitlist.slug,
|
|
||||||
name=waitlist.name,
|
|
||||||
subHeading=waitlist.subHeading,
|
|
||||||
videoUrl=waitlist.videoUrl,
|
|
||||||
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
|
|
||||||
imageUrls=waitlist.imageUrls or [],
|
|
||||||
description=waitlist.description,
|
|
||||||
categories=waitlist.categories,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_waitlist() -> list[store_model.StoreWaitlistEntry]:
|
|
||||||
"""Get all active waitlists for public display."""
|
|
||||||
try:
|
|
||||||
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
|
|
||||||
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out closed/done waitlists and sort by votes (descending)
|
|
||||||
excluded_statuses = {
|
|
||||||
prisma.enums.WaitlistExternalStatus.CANCELED,
|
|
||||||
prisma.enums.WaitlistExternalStatus.DONE,
|
|
||||||
}
|
|
||||||
active_waitlists = [w for w in waitlists if w.status not in excluded_statuses]
|
|
||||||
sorted_list = sorted(active_waitlists, key=lambda x: x.votes, reverse=True)
|
|
||||||
|
|
||||||
return [_waitlist_to_store_entry(w) for w in sorted_list]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching waitlists: {e}")
|
|
||||||
raise DatabaseError("Failed to fetch waitlists") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_waitlist_memberships(user_id: str) -> list[str]:
|
|
||||||
"""Get all waitlist IDs that a user has joined."""
|
|
||||||
try:
|
|
||||||
user = await prisma.models.User.prisma().find_unique(
|
|
||||||
where={"id": user_id},
|
|
||||||
include={"joinedWaitlists": True},
|
|
||||||
)
|
|
||||||
if not user or not user.joinedWaitlists:
|
|
||||||
return []
|
|
||||||
return [w.id for w in user.joinedWaitlists]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching user waitlist memberships: {e}")
|
|
||||||
raise DatabaseError("Failed to fetch waitlist memberships") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def add_user_to_waitlist(
|
|
||||||
waitlist_id: str, user_id: str | None, email: str | None
|
|
||||||
) -> store_model.StoreWaitlistEntry:
|
|
||||||
"""
|
|
||||||
Add a user to a waitlist.
|
|
||||||
|
|
||||||
For logged-in users: connects via joinedUsers relation
|
|
||||||
For anonymous users: adds email to unaffiliatedEmailUsers array
|
|
||||||
"""
|
|
||||||
if not user_id and not email:
|
|
||||||
raise ValueError("Either user_id or email must be provided")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Find the waitlist
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not waitlist:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
|
||||||
|
|
||||||
if waitlist.isDeleted:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} is no longer available")
|
|
||||||
|
|
||||||
if waitlist.status in [
|
|
||||||
prisma.enums.WaitlistExternalStatus.CANCELED,
|
|
||||||
prisma.enums.WaitlistExternalStatus.DONE,
|
|
||||||
]:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} is closed")
|
|
||||||
|
|
||||||
if user_id:
|
|
||||||
# Check if user already joined
|
|
||||||
joined_user_ids = [u.id for u in (waitlist.joinedUsers or [])]
|
|
||||||
if user_id in joined_user_ids:
|
|
||||||
# Already joined - return waitlist info
|
|
||||||
logger.debug(f"User {user_id} already joined waitlist {waitlist_id}")
|
|
||||||
else:
|
|
||||||
# Connect user to waitlist
|
|
||||||
await prisma.models.WaitlistEntry.prisma().update(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
data={"joinedUsers": {"connect": [{"id": user_id}]}},
|
|
||||||
)
|
|
||||||
logger.info(f"User {user_id} joined waitlist {waitlist_id}")
|
|
||||||
|
|
||||||
# If user was previously in email list, remove them
|
|
||||||
# Use transaction to prevent race conditions
|
|
||||||
if email:
|
|
||||||
async with transaction() as tx:
|
|
||||||
current_waitlist = await tx.waitlistentry.find_unique(
|
|
||||||
where={"id": waitlist_id}
|
|
||||||
)
|
|
||||||
if current_waitlist and email in (
|
|
||||||
current_waitlist.unaffiliatedEmailUsers or []
|
|
||||||
):
|
|
||||||
updated_emails: list[str] = [
|
|
||||||
e
|
|
||||||
for e in (current_waitlist.unaffiliatedEmailUsers or [])
|
|
||||||
if e != email
|
|
||||||
]
|
|
||||||
await tx.waitlistentry.update(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
data={"unaffiliatedEmailUsers": updated_emails},
|
|
||||||
)
|
|
||||||
elif email:
|
|
||||||
# Add email to unaffiliated list if not already present
|
|
||||||
# Use transaction to prevent race conditions with concurrent signups
|
|
||||||
async with transaction() as tx:
|
|
||||||
# Re-fetch within transaction to get latest state
|
|
||||||
current_waitlist = await tx.waitlistentry.find_unique(
|
|
||||||
where={"id": waitlist_id}
|
|
||||||
)
|
|
||||||
if current_waitlist:
|
|
||||||
current_emails: list[str] = list(
|
|
||||||
current_waitlist.unaffiliatedEmailUsers or []
|
|
||||||
)
|
|
||||||
if email not in current_emails:
|
|
||||||
current_emails.append(email)
|
|
||||||
await tx.waitlistentry.update(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
data={"unaffiliatedEmailUsers": current_emails},
|
|
||||||
)
|
|
||||||
logger.info(f"Email {email} added to waitlist {waitlist_id}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Email {email} already on waitlist {waitlist_id}")
|
|
||||||
|
|
||||||
# Re-fetch to return updated data
|
|
||||||
updated_waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id}
|
|
||||||
)
|
|
||||||
return _waitlist_to_store_entry(updated_waitlist or waitlist)
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error adding user to waitlist: {e}")
|
|
||||||
raise DatabaseError("Failed to add user to waitlist") from e
|
|
||||||
|
|
||||||
|
|
||||||
# ============== Admin Waitlist Functions ==============
|
|
||||||
|
|
||||||
|
|
||||||
def _waitlist_to_admin_response(
|
|
||||||
waitlist: prisma.models.WaitlistEntry,
|
|
||||||
) -> store_model.WaitlistAdminResponse:
|
|
||||||
"""Convert a WaitlistEntry to WaitlistAdminResponse."""
|
|
||||||
joined_count = len(waitlist.joinedUsers) if waitlist.joinedUsers else 0
|
|
||||||
email_count = (
|
|
||||||
len(waitlist.unaffiliatedEmailUsers) if waitlist.unaffiliatedEmailUsers else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return store_model.WaitlistAdminResponse(
|
|
||||||
id=waitlist.id,
|
|
||||||
createdAt=waitlist.createdAt.isoformat() if waitlist.createdAt else "",
|
|
||||||
updatedAt=waitlist.updatedAt.isoformat() if waitlist.updatedAt else "",
|
|
||||||
slug=waitlist.slug,
|
|
||||||
name=waitlist.name,
|
|
||||||
subHeading=waitlist.subHeading,
|
|
||||||
description=waitlist.description,
|
|
||||||
categories=waitlist.categories,
|
|
||||||
imageUrls=waitlist.imageUrls or [],
|
|
||||||
videoUrl=waitlist.videoUrl,
|
|
||||||
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
|
|
||||||
status=waitlist.status or prisma.enums.WaitlistExternalStatus.NOT_STARTED,
|
|
||||||
votes=waitlist.votes,
|
|
||||||
signupCount=joined_count + email_count,
|
|
||||||
storeListingId=waitlist.storeListingId,
|
|
||||||
owningUserId=waitlist.owningUserId,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_waitlist_admin(
|
|
||||||
admin_user_id: str,
|
|
||||||
data: store_model.WaitlistCreateRequest,
|
|
||||||
) -> store_model.WaitlistAdminResponse:
|
|
||||||
"""Create a new waitlist (admin only)."""
|
|
||||||
logger.info(f"Admin {admin_user_id} creating waitlist: {data.name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().create(
|
|
||||||
data=prisma.types.WaitlistEntryCreateInput(
|
|
||||||
name=data.name,
|
|
||||||
slug=data.slug,
|
|
||||||
subHeading=data.subHeading,
|
|
||||||
description=data.description,
|
|
||||||
categories=data.categories,
|
|
||||||
imageUrls=data.imageUrls,
|
|
||||||
videoUrl=data.videoUrl,
|
|
||||||
agentOutputDemoUrl=data.agentOutputDemoUrl,
|
|
||||||
owningUserId=admin_user_id,
|
|
||||||
status=prisma.enums.WaitlistExternalStatus.NOT_STARTED,
|
|
||||||
),
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
return _waitlist_to_admin_response(waitlist)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating waitlist: {e}")
|
|
||||||
raise DatabaseError("Failed to create waitlist") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def get_waitlists_admin() -> store_model.WaitlistAdminListResponse:
|
|
||||||
"""Get all waitlists with admin details."""
|
|
||||||
try:
|
|
||||||
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
|
|
||||||
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
order={"createdAt": "desc"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return store_model.WaitlistAdminListResponse(
|
|
||||||
waitlists=[_waitlist_to_admin_response(w) for w in waitlists],
|
|
||||||
totalCount=len(waitlists),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching waitlists for admin: {e}")
|
|
||||||
raise DatabaseError("Failed to fetch waitlists") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def get_waitlist_admin(
|
|
||||||
waitlist_id: str,
|
|
||||||
) -> store_model.WaitlistAdminResponse:
|
|
||||||
"""Get a single waitlist with admin details."""
|
|
||||||
try:
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not waitlist:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
|
||||||
|
|
||||||
if waitlist.isDeleted:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
|
|
||||||
|
|
||||||
return _waitlist_to_admin_response(waitlist)
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching waitlist {waitlist_id}: {e}")
|
|
||||||
raise DatabaseError("Failed to fetch waitlist") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def update_waitlist_admin(
|
|
||||||
waitlist_id: str,
|
|
||||||
data: store_model.WaitlistUpdateRequest,
|
|
||||||
) -> store_model.WaitlistAdminResponse:
|
|
||||||
"""Update a waitlist (admin only)."""
|
|
||||||
logger.info(f"Updating waitlist {waitlist_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if waitlist exists first
|
|
||||||
existing = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not existing:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
|
||||||
|
|
||||||
if existing.isDeleted:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
|
|
||||||
|
|
||||||
# Build update data from explicitly provided fields
|
|
||||||
# Use model_fields_set to allow clearing fields by setting them to None
|
|
||||||
field_mappings = {
|
|
||||||
"name": data.name,
|
|
||||||
"slug": data.slug,
|
|
||||||
"subHeading": data.subHeading,
|
|
||||||
"description": data.description,
|
|
||||||
"categories": data.categories,
|
|
||||||
"imageUrls": data.imageUrls,
|
|
||||||
"videoUrl": data.videoUrl,
|
|
||||||
"agentOutputDemoUrl": data.agentOutputDemoUrl,
|
|
||||||
"storeListingId": data.storeListingId,
|
|
||||||
}
|
|
||||||
update_data: dict[str, Any] = {
|
|
||||||
k: v for k, v in field_mappings.items() if k in data.model_fields_set
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add status if provided (already validated as enum by Pydantic)
|
|
||||||
if "status" in data.model_fields_set and data.status is not None:
|
|
||||||
update_data["status"] = data.status
|
|
||||||
|
|
||||||
if not update_data:
|
|
||||||
# No updates, just return current data
|
|
||||||
return await get_waitlist_admin(waitlist_id)
|
|
||||||
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().update(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
data=prisma.types.WaitlistEntryUpdateInput(**update_data),
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
# We already verified existence above, so this should never be None
|
|
||||||
assert waitlist is not None
|
|
||||||
return _waitlist_to_admin_response(waitlist)
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating waitlist {waitlist_id}: {e}")
|
|
||||||
raise DatabaseError("Failed to update waitlist") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_waitlist_admin(waitlist_id: str) -> None:
|
|
||||||
"""Soft delete a waitlist (admin only)."""
|
|
||||||
logger.info(f"Soft deleting waitlist {waitlist_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if waitlist exists first
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not waitlist:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
|
||||||
|
|
||||||
if waitlist.isDeleted:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} has already been deleted")
|
|
||||||
|
|
||||||
await prisma.models.WaitlistEntry.prisma().update(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
data={"isDeleted": True},
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting waitlist {waitlist_id}: {e}")
|
|
||||||
raise DatabaseError("Failed to delete waitlist") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def get_waitlist_signups_admin(
|
|
||||||
waitlist_id: str,
|
|
||||||
) -> store_model.WaitlistSignupListResponse:
|
|
||||||
"""Get all signups for a waitlist (admin only)."""
|
|
||||||
try:
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not waitlist:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
|
||||||
|
|
||||||
signups: list[store_model.WaitlistSignup] = []
|
|
||||||
|
|
||||||
# Add user signups
|
|
||||||
for user in waitlist.joinedUsers or []:
|
|
||||||
signups.append(
|
|
||||||
store_model.WaitlistSignup(
|
|
||||||
type="user",
|
|
||||||
userId=user.id,
|
|
||||||
email=user.email,
|
|
||||||
username=user.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add email signups
|
|
||||||
for email in waitlist.unaffiliatedEmailUsers or []:
|
|
||||||
signups.append(
|
|
||||||
store_model.WaitlistSignup(
|
|
||||||
type="email",
|
|
||||||
email=email,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return store_model.WaitlistSignupListResponse(
|
|
||||||
waitlistId=waitlist_id,
|
|
||||||
signups=signups,
|
|
||||||
totalCount=len(signups),
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching signups for waitlist {waitlist_id}: {e}")
|
|
||||||
raise DatabaseError("Failed to fetch waitlist signups") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def link_waitlist_to_listing_admin(
|
|
||||||
waitlist_id: str,
|
|
||||||
store_listing_id: str,
|
|
||||||
) -> store_model.WaitlistAdminResponse:
|
|
||||||
"""Link a waitlist to a store listing (admin only)."""
|
|
||||||
logger.info(f"Linking waitlist {waitlist_id} to listing {store_listing_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Verify the waitlist exists
|
|
||||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
|
||||||
where={"id": waitlist_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not waitlist:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
|
||||||
|
|
||||||
if waitlist.isDeleted:
|
|
||||||
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
|
|
||||||
|
|
||||||
# Verify the store listing exists
|
|
||||||
listing = await prisma.models.StoreListing.prisma().find_unique(
|
|
||||||
where={"id": store_listing_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not listing:
|
|
||||||
raise ValueError(f"Store listing {store_listing_id} not found")
|
|
||||||
|
|
||||||
updated_waitlist = await prisma.models.WaitlistEntry.prisma().update(
|
|
||||||
where={"id": waitlist_id},
|
|
||||||
data={"StoreListing": {"connect": {"id": store_listing_id}}},
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
# We already verified existence above, so this should never be None
|
|
||||||
assert updated_waitlist is not None
|
|
||||||
return _waitlist_to_admin_response(updated_waitlist)
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error linking waitlist to listing: {e}")
|
|
||||||
raise DatabaseError("Failed to link waitlist to listing") from e
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_waitlist_users_on_launch(
|
|
||||||
store_listing_id: str,
|
|
||||||
agent_name: str,
|
|
||||||
store_url: str,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Notify all users on waitlists linked to a store listing when the agent is launched.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
store_listing_id: The ID of the store listing that was approved
|
|
||||||
agent_name: The name of the approved agent
|
|
||||||
store_url: The URL to the agent's store page
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The number of notifications sent
|
|
||||||
"""
|
|
||||||
logger.info(f"Notifying waitlist users for store listing {store_listing_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Find all active waitlists linked to this store listing
|
|
||||||
# Exclude DONE and CANCELED to prevent duplicate notifications on re-approval
|
|
||||||
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
|
|
||||||
where={
|
|
||||||
"storeListingId": store_listing_id,
|
|
||||||
"isDeleted": False,
|
|
||||||
"status": {
|
|
||||||
"not_in": [
|
|
||||||
prisma.enums.WaitlistExternalStatus.DONE,
|
|
||||||
prisma.enums.WaitlistExternalStatus.CANCELED,
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include={"joinedUsers": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not waitlists:
|
|
||||||
logger.info(
|
|
||||||
f"No active waitlists found for store listing {store_listing_id}"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
notification_count = 0
|
|
||||||
launched_at = datetime.now(tz=timezone.utc)
|
|
||||||
|
|
||||||
for waitlist in waitlists:
|
|
||||||
# Track notification results for this waitlist
|
|
||||||
users_to_notify = waitlist.joinedUsers or []
|
|
||||||
failed_user_ids: list[str] = []
|
|
||||||
|
|
||||||
# Notify registered users
|
|
||||||
for user in users_to_notify:
|
|
||||||
try:
|
|
||||||
notification_data = WaitlistLaunchData(
|
|
||||||
agent_name=agent_name,
|
|
||||||
waitlist_name=waitlist.name,
|
|
||||||
store_url=store_url,
|
|
||||||
launched_at=launched_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
notification_event = NotificationEventModel[WaitlistLaunchData](
|
|
||||||
user_id=user.id,
|
|
||||||
type=prisma.enums.NotificationType.WAITLIST_LAUNCH,
|
|
||||||
data=notification_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
await queue_notification_async(notification_event)
|
|
||||||
notification_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to send waitlist launch notification to user {user.id}: {e}"
|
|
||||||
)
|
|
||||||
failed_user_ids.append(user.id)
|
|
||||||
|
|
||||||
# Note: For unaffiliated email users, you would need to send emails directly
|
|
||||||
# since they don't have user IDs for the notification system.
|
|
||||||
# This could be done via a separate email service.
|
|
||||||
# For now, we log these for potential manual follow-up or future implementation.
|
|
||||||
has_pending_email_users = bool(waitlist.unaffiliatedEmailUsers)
|
|
||||||
if has_pending_email_users:
|
|
||||||
logger.info(
|
|
||||||
f"Waitlist {waitlist.id} has {len(waitlist.unaffiliatedEmailUsers)} "
|
|
||||||
f"unaffiliated email users that need email notifications"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only mark waitlist as DONE if all registered user notifications succeeded
|
|
||||||
# AND there are no unaffiliated email users still waiting for notifications
|
|
||||||
if not failed_user_ids and not has_pending_email_users:
|
|
||||||
await prisma.models.WaitlistEntry.prisma().update(
|
|
||||||
where={"id": waitlist.id},
|
|
||||||
data={"status": prisma.enums.WaitlistExternalStatus.DONE},
|
|
||||||
)
|
|
||||||
logger.info(f"Updated waitlist {waitlist.id} status to DONE")
|
|
||||||
elif failed_user_ids:
|
|
||||||
logger.warning(
|
|
||||||
f"Waitlist {waitlist.id} not marked as DONE due to "
|
|
||||||
f"{len(failed_user_ids)} failed notifications"
|
|
||||||
)
|
|
||||||
elif has_pending_email_users:
|
|
||||||
logger.warning(
|
|
||||||
f"Waitlist {waitlist.id} not marked as DONE due to "
|
|
||||||
f"{len(waitlist.unaffiliatedEmailUsers)} pending email-only users"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Sent {notification_count} waitlist launch notifications for store listing {store_listing_id}"
|
|
||||||
)
|
|
||||||
return notification_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error notifying waitlist users for store listing {store_listing_id}: {e}"
|
|
||||||
)
|
|
||||||
# Don't raise - we don't want to fail the approval process
|
|
||||||
return 0
|
|
||||||
|
|||||||
@@ -224,102 +224,6 @@ class ReviewSubmissionRequest(pydantic.BaseModel):
|
|||||||
internal_comments: str | None = None # Private admin notes
|
internal_comments: str | None = None # Private admin notes
|
||||||
|
|
||||||
|
|
||||||
class StoreWaitlistEntry(pydantic.BaseModel):
|
|
||||||
"""Public waitlist entry - no PII fields exposed."""
|
|
||||||
|
|
||||||
waitlistId: str
|
|
||||||
slug: str
|
|
||||||
|
|
||||||
# Content fields
|
|
||||||
name: str
|
|
||||||
subHeading: str
|
|
||||||
videoUrl: str | None = None
|
|
||||||
agentOutputDemoUrl: str | None = None
|
|
||||||
imageUrls: list[str]
|
|
||||||
description: str
|
|
||||||
categories: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class StoreWaitlistsAllResponse(pydantic.BaseModel):
|
|
||||||
listings: list[StoreWaitlistEntry]
|
|
||||||
|
|
||||||
|
|
||||||
# Admin Waitlist Models
|
|
||||||
|
|
||||||
|
|
||||||
class WaitlistCreateRequest(pydantic.BaseModel):
|
|
||||||
"""Request model for creating a new waitlist."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
subHeading: str
|
|
||||||
description: str
|
|
||||||
categories: list[str] = []
|
|
||||||
imageUrls: list[str] = []
|
|
||||||
videoUrl: str | None = None
|
|
||||||
agentOutputDemoUrl: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WaitlistUpdateRequest(pydantic.BaseModel):
|
|
||||||
"""Request model for updating a waitlist."""
|
|
||||||
|
|
||||||
name: str | None = None
|
|
||||||
slug: str | None = None
|
|
||||||
subHeading: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
categories: list[str] | None = None
|
|
||||||
imageUrls: list[str] | None = None
|
|
||||||
videoUrl: str | None = None
|
|
||||||
agentOutputDemoUrl: str | None = None
|
|
||||||
status: prisma.enums.WaitlistExternalStatus | None = None
|
|
||||||
storeListingId: str | None = None # Link to a store listing
|
|
||||||
|
|
||||||
|
|
||||||
class WaitlistAdminResponse(pydantic.BaseModel):
|
|
||||||
"""Admin response model with full waitlist details including internal data."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
createdAt: str
|
|
||||||
updatedAt: str
|
|
||||||
slug: str
|
|
||||||
name: str
|
|
||||||
subHeading: str
|
|
||||||
description: str
|
|
||||||
categories: list[str]
|
|
||||||
imageUrls: list[str]
|
|
||||||
videoUrl: str | None = None
|
|
||||||
agentOutputDemoUrl: str | None = None
|
|
||||||
status: prisma.enums.WaitlistExternalStatus
|
|
||||||
votes: int
|
|
||||||
signupCount: int # Total count of joinedUsers + unaffiliatedEmailUsers
|
|
||||||
storeListingId: str | None = None
|
|
||||||
owningUserId: str
|
|
||||||
|
|
||||||
|
|
||||||
class WaitlistSignup(pydantic.BaseModel):
|
|
||||||
"""Individual signup entry for a waitlist."""
|
|
||||||
|
|
||||||
type: str # "user" or "email"
|
|
||||||
userId: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
username: str | None = None # For user signups
|
|
||||||
|
|
||||||
|
|
||||||
class WaitlistSignupListResponse(pydantic.BaseModel):
|
|
||||||
"""Response model for listing waitlist signups."""
|
|
||||||
|
|
||||||
waitlistId: str
|
|
||||||
signups: list[WaitlistSignup]
|
|
||||||
totalCount: int
|
|
||||||
|
|
||||||
|
|
||||||
class WaitlistAdminListResponse(pydantic.BaseModel):
|
|
||||||
"""Response model for listing all waitlists (admin view)."""
|
|
||||||
|
|
||||||
waitlists: list[WaitlistAdminResponse]
|
|
||||||
totalCount: int
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedSearchResult(pydantic.BaseModel):
|
class UnifiedSearchResult(pydantic.BaseModel):
|
||||||
"""A single result from unified hybrid search across all content types."""
|
"""A single result from unified hybrid search across all content types."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import autogpt_libs.auth
|
|||||||
import fastapi
|
import fastapi
|
||||||
import fastapi.responses
|
import fastapi.responses
|
||||||
import prisma.enums
|
import prisma.enums
|
||||||
from autogpt_libs.auth.dependencies import get_optional_user_id
|
|
||||||
|
|
||||||
import backend.data.graph
|
import backend.data.graph
|
||||||
import backend.util.json
|
import backend.util.json
|
||||||
@@ -82,74 +81,6 @@ async def update_or_create_profile(
|
|||||||
return updated_profile
|
return updated_profile
|
||||||
|
|
||||||
|
|
||||||
##############################################
|
|
||||||
############## Waitlist Endpoints ############
|
|
||||||
##############################################
|
|
||||||
@router.get(
|
|
||||||
"/waitlist",
|
|
||||||
summary="Get the agent waitlist",
|
|
||||||
tags=["store", "public"],
|
|
||||||
response_model=store_model.StoreWaitlistsAllResponse,
|
|
||||||
)
|
|
||||||
async def get_waitlist():
|
|
||||||
"""
|
|
||||||
Get all active waitlists for public display.
|
|
||||||
"""
|
|
||||||
waitlists = await store_db.get_waitlist()
|
|
||||||
return store_model.StoreWaitlistsAllResponse(listings=waitlists)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/waitlist/my-memberships",
|
|
||||||
summary="Get waitlist IDs the current user has joined",
|
|
||||||
tags=["store", "private"],
|
|
||||||
)
|
|
||||||
async def get_my_waitlist_memberships(
|
|
||||||
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
|
|
||||||
) -> list[str]:
|
|
||||||
"""Returns list of waitlist IDs the authenticated user has joined."""
|
|
||||||
return await store_db.get_user_waitlist_memberships(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
path="/waitlist/{waitlist_id}/join",
|
|
||||||
summary="Add self to the agent waitlist",
|
|
||||||
tags=["store", "public"],
|
|
||||||
response_model=store_model.StoreWaitlistEntry,
|
|
||||||
)
|
|
||||||
async def add_self_to_waitlist(
|
|
||||||
user_id: str | None = fastapi.Security(get_optional_user_id),
|
|
||||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist to join"),
|
|
||||||
email: str | None = fastapi.Body(
|
|
||||||
default=None, embed=True, description="Email address for unauthenticated users"
|
|
||||||
),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Add the current user to the agent waitlist.
|
|
||||||
"""
|
|
||||||
if not user_id and not email:
|
|
||||||
raise fastapi.HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Either user authentication or email address is required",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
waitlist_entry = await store_db.add_user_to_waitlist(
|
|
||||||
waitlist_id=waitlist_id, user_id=user_id, email=email
|
|
||||||
)
|
|
||||||
return waitlist_entry
|
|
||||||
except ValueError as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
if "not found" in error_msg:
|
|
||||||
raise fastapi.HTTPException(status_code=404, detail="Waitlist not found")
|
|
||||||
# Waitlist exists but is closed or unavailable
|
|
||||||
raise fastapi.HTTPException(status_code=400, detail=error_msg)
|
|
||||||
except Exception:
|
|
||||||
raise fastapi.HTTPException(
|
|
||||||
status_code=500, detail="An error occurred while joining the waitlist"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
##############################################
|
##############################################
|
||||||
############### Agent Endpoints ##############
|
############### Agent Endpoints ##############
|
||||||
##############################################
|
##############################################
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from prisma.errors import PrismaError
|
|||||||
import backend.api.features.admin.credit_admin_routes
|
import backend.api.features.admin.credit_admin_routes
|
||||||
import backend.api.features.admin.execution_analytics_routes
|
import backend.api.features.admin.execution_analytics_routes
|
||||||
import backend.api.features.admin.store_admin_routes
|
import backend.api.features.admin.store_admin_routes
|
||||||
import backend.api.features.admin.waitlist_admin_routes
|
|
||||||
import backend.api.features.builder
|
import backend.api.features.builder
|
||||||
import backend.api.features.builder.routes
|
import backend.api.features.builder.routes
|
||||||
import backend.api.features.chat.routes as chat_routes
|
import backend.api.features.chat.routes as chat_routes
|
||||||
@@ -307,11 +306,6 @@ app.include_router(
|
|||||||
tags=["v2", "admin"],
|
tags=["v2", "admin"],
|
||||||
prefix="/api/store",
|
prefix="/api/store",
|
||||||
)
|
)
|
||||||
app.include_router(
|
|
||||||
backend.api.features.admin.waitlist_admin_routes.router,
|
|
||||||
tags=["v2", "admin"],
|
|
||||||
prefix="/api/store",
|
|
||||||
)
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
backend.api.features.admin.credit_admin_routes.router,
|
backend.api.features.admin.credit_admin_routes.router,
|
||||||
tags=["v2", "admin"],
|
tags=["v2", "admin"],
|
||||||
|
|||||||
@@ -162,16 +162,8 @@ class LinearClient:
|
|||||||
"searchTerm": team_name,
|
"searchTerm": team_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await self.query(query, variables)
|
team_id = await self.query(query, variables)
|
||||||
nodes = result["teams"]["nodes"]
|
return team_id["teams"]["nodes"][0]["id"]
|
||||||
|
|
||||||
if not nodes:
|
|
||||||
raise LinearAPIException(
|
|
||||||
f"Team '{team_name}' not found. Check the team name or key and try again.",
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nodes[0]["id"]
|
|
||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@@ -248,44 +240,17 @@ class LinearClient:
|
|||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def try_search_issues(
|
async def try_search_issues(self, term: str) -> list[Issue]:
|
||||||
self,
|
|
||||||
term: str,
|
|
||||||
max_results: int = 10,
|
|
||||||
team_id: str | None = None,
|
|
||||||
) -> list[Issue]:
|
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
query SearchIssues(
|
query SearchIssues($term: String!, $includeComments: Boolean!) {
|
||||||
$term: String!,
|
searchIssues(term: $term, includeComments: $includeComments) {
|
||||||
$first: Int,
|
|
||||||
$teamId: String
|
|
||||||
) {
|
|
||||||
searchIssues(
|
|
||||||
term: $term,
|
|
||||||
first: $first,
|
|
||||||
teamId: $teamId
|
|
||||||
) {
|
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
identifier
|
identifier
|
||||||
title
|
title
|
||||||
description
|
description
|
||||||
priority
|
priority
|
||||||
createdAt
|
|
||||||
state {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
type
|
|
||||||
}
|
|
||||||
project {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
assignee {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,8 +258,7 @@ class LinearClient:
|
|||||||
|
|
||||||
variables: dict[str, Any] = {
|
variables: dict[str, Any] = {
|
||||||
"term": term,
|
"term": term,
|
||||||
"first": max_results,
|
"includeComments": True,
|
||||||
"teamId": team_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
issues = await self.query(query, variables)
|
issues = await self.query(query, variables)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from ._config import (
|
|||||||
LinearScope,
|
LinearScope,
|
||||||
linear,
|
linear,
|
||||||
)
|
)
|
||||||
from .models import CreateIssueResponse, Issue, State
|
from .models import CreateIssueResponse, Issue
|
||||||
|
|
||||||
|
|
||||||
class LinearCreateIssueBlock(Block):
|
class LinearCreateIssueBlock(Block):
|
||||||
@@ -135,20 +135,9 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
description="Linear credentials with read permissions",
|
description="Linear credentials with read permissions",
|
||||||
required_scopes={LinearScope.READ},
|
required_scopes={LinearScope.READ},
|
||||||
)
|
)
|
||||||
max_results: int = SchemaField(
|
|
||||||
description="Maximum number of results to return",
|
|
||||||
default=10,
|
|
||||||
ge=1,
|
|
||||||
le=100,
|
|
||||||
)
|
|
||||||
team_name: str | None = SchemaField(
|
|
||||||
description="Optional team name to filter results (e.g., 'Internal', 'Open Source')",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
class Output(BlockSchemaOutput):
|
||||||
issues: list[Issue] = SchemaField(description="List of issues")
|
issues: list[Issue] = SchemaField(description="List of issues")
|
||||||
error: str = SchemaField(description="Error message if the search failed")
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -156,11 +145,8 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
description="Searches for issues on Linear",
|
description="Searches for issues on Linear",
|
||||||
input_schema=self.Input,
|
input_schema=self.Input,
|
||||||
output_schema=self.Output,
|
output_schema=self.Output,
|
||||||
categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING},
|
|
||||||
test_input={
|
test_input={
|
||||||
"term": "Test issue",
|
"term": "Test issue",
|
||||||
"max_results": 10,
|
|
||||||
"team_name": None,
|
|
||||||
"credentials": TEST_CREDENTIALS_INPUT_OAUTH,
|
"credentials": TEST_CREDENTIALS_INPUT_OAUTH,
|
||||||
},
|
},
|
||||||
test_credentials=TEST_CREDENTIALS_OAUTH,
|
test_credentials=TEST_CREDENTIALS_OAUTH,
|
||||||
@@ -170,14 +156,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
[
|
[
|
||||||
Issue(
|
Issue(
|
||||||
id="abc123",
|
id="abc123",
|
||||||
identifier="TST-123",
|
identifier="abc123",
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
description="Test description",
|
description="Test description",
|
||||||
priority=1,
|
priority=1,
|
||||||
state=State(
|
|
||||||
id="state1", name="In Progress", type="started"
|
|
||||||
),
|
|
||||||
createdAt="2026-01-15T10:00:00.000Z",
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -186,12 +168,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
"search_issues": lambda *args, **kwargs: [
|
"search_issues": lambda *args, **kwargs: [
|
||||||
Issue(
|
Issue(
|
||||||
id="abc123",
|
id="abc123",
|
||||||
identifier="TST-123",
|
identifier="abc123",
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
description="Test description",
|
description="Test description",
|
||||||
priority=1,
|
priority=1,
|
||||||
state=State(id="state1", name="In Progress", type="started"),
|
|
||||||
createdAt="2026-01-15T10:00:00.000Z",
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -201,22 +181,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
async def search_issues(
|
async def search_issues(
|
||||||
credentials: OAuth2Credentials | APIKeyCredentials,
|
credentials: OAuth2Credentials | APIKeyCredentials,
|
||||||
term: str,
|
term: str,
|
||||||
max_results: int = 10,
|
|
||||||
team_name: str | None = None,
|
|
||||||
) -> list[Issue]:
|
) -> list[Issue]:
|
||||||
client = LinearClient(credentials=credentials)
|
client = LinearClient(credentials=credentials)
|
||||||
|
response: list[Issue] = await client.try_search_issues(term=term)
|
||||||
# Resolve team name to ID if provided
|
return response
|
||||||
# Raises LinearAPIException with descriptive message if team not found
|
|
||||||
team_id: str | None = None
|
|
||||||
if team_name:
|
|
||||||
team_id = await client.try_get_team_by_name(team_name=team_name)
|
|
||||||
|
|
||||||
return await client.try_search_issues(
|
|
||||||
term=term,
|
|
||||||
max_results=max_results,
|
|
||||||
team_id=team_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -228,10 +196,7 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
"""Execute the issue search"""
|
"""Execute the issue search"""
|
||||||
try:
|
try:
|
||||||
issues = await self.search_issues(
|
issues = await self.search_issues(
|
||||||
credentials=credentials,
|
credentials=credentials, term=input_data.term
|
||||||
term=input_data.term,
|
|
||||||
max_results=input_data.max_results,
|
|
||||||
team_name=input_data.team_name,
|
|
||||||
)
|
)
|
||||||
yield "issues", issues
|
yield "issues", issues
|
||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
|
|||||||
@@ -36,21 +36,12 @@ class Project(BaseModel):
|
|||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class State(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
type: str | None = (
|
|
||||||
None # Workflow state type (e.g., "triage", "backlog", "started", "completed", "canceled")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Issue(BaseModel):
|
class Issue(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
identifier: str
|
identifier: str
|
||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
priority: int
|
priority: int
|
||||||
state: State | None = None
|
|
||||||
project: Project | None = None
|
project: Project | None = None
|
||||||
createdAt: str | None = None
|
createdAt: str | None = None
|
||||||
comments: list[Comment] | None = None
|
comments: list[Comment] | None = None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import (
|
|||||||
cast,
|
cast,
|
||||||
get_args,
|
get_args,
|
||||||
)
|
)
|
||||||
|
from urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from prisma.enums import CreditTransactionType, OnboardingStep
|
from prisma.enums import CreditTransactionType, OnboardingStep
|
||||||
@@ -41,7 +42,6 @@ from typing_extensions import TypedDict
|
|||||||
|
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
from backend.util.json import loads as json_loads
|
from backend.util.json import loads as json_loads
|
||||||
from backend.util.request import parse_url
|
|
||||||
from backend.util.settings import Secrets
|
from backend.util.settings import Secrets
|
||||||
|
|
||||||
# Type alias for any provider name (including custom ones)
|
# Type alias for any provider name (including custom ones)
|
||||||
@@ -397,25 +397,19 @@ class HostScopedCredentials(_BaseCredentials):
|
|||||||
def matches_url(self, url: str) -> bool:
|
def matches_url(self, url: str) -> bool:
|
||||||
"""Check if this credential should be applied to the given URL."""
|
"""Check if this credential should be applied to the given URL."""
|
||||||
|
|
||||||
request_host, request_port = _extract_host_from_url(url)
|
parsed_url = urlparse(url)
|
||||||
cred_scope_host, cred_scope_port = _extract_host_from_url(self.host)
|
# Extract hostname without port
|
||||||
|
request_host = parsed_url.hostname
|
||||||
if not request_host:
|
if not request_host:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If a port is specified in credential host, the request host port must match
|
# Simple host matching - exact match or wildcard subdomain match
|
||||||
if cred_scope_port is not None and request_port != cred_scope_port:
|
if self.host == request_host:
|
||||||
return False
|
|
||||||
# Non-standard ports are only allowed if explicitly specified in credential host
|
|
||||||
elif cred_scope_port is None and request_port not in (80, 443, None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Simple host matching
|
|
||||||
if cred_scope_host == request_host:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Support wildcard matching (e.g., "*.example.com" matches "api.example.com")
|
# Support wildcard matching (e.g., "*.example.com" matches "api.example.com")
|
||||||
if cred_scope_host.startswith("*."):
|
if self.host.startswith("*."):
|
||||||
domain = cred_scope_host[2:] # Remove "*."
|
domain = self.host[2:] # Remove "*."
|
||||||
return request_host.endswith(f".{domain}") or request_host == domain
|
return request_host.endswith(f".{domain}") or request_host == domain
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -557,13 +551,13 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_host_from_url(url: str) -> tuple[str, int | None]:
|
def _extract_host_from_url(url: str) -> str:
|
||||||
"""Extract host and port from URL for grouping host-scoped credentials."""
|
"""Extract host from URL for grouping host-scoped credentials."""
|
||||||
try:
|
try:
|
||||||
parsed = parse_url(url)
|
parsed = urlparse(url)
|
||||||
return parsed.hostname or url, parsed.port
|
return parsed.hostname or url
|
||||||
except Exception:
|
except Exception:
|
||||||
return "", None
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
||||||
@@ -612,7 +606,7 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
providers = frozenset(
|
providers = frozenset(
|
||||||
[cast(CP, "http")]
|
[cast(CP, "http")]
|
||||||
+ [
|
+ [
|
||||||
cast(CP, parse_url(str(value)).netloc)
|
cast(CP, _extract_host_from_url(str(value)))
|
||||||
for value in field.discriminator_values
|
for value in field.discriminator_values
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,23 +79,10 @@ class TestHostScopedCredentials:
|
|||||||
headers={"Authorization": SecretStr("Bearer token")},
|
headers={"Authorization": SecretStr("Bearer token")},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-standard ports require explicit port in credential host
|
assert creds.matches_url("http://localhost:8080/api/v1")
|
||||||
assert not creds.matches_url("http://localhost:8080/api/v1")
|
|
||||||
assert creds.matches_url("https://localhost:443/secure/endpoint")
|
assert creds.matches_url("https://localhost:443/secure/endpoint")
|
||||||
assert creds.matches_url("http://localhost/simple")
|
assert creds.matches_url("http://localhost/simple")
|
||||||
|
|
||||||
def test_matches_url_with_explicit_port(self):
|
|
||||||
"""Test URL matching with explicit port in credential host."""
|
|
||||||
creds = HostScopedCredentials(
|
|
||||||
provider="custom",
|
|
||||||
host="localhost:8080",
|
|
||||||
headers={"Authorization": SecretStr("Bearer token")},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert creds.matches_url("http://localhost:8080/api/v1")
|
|
||||||
assert not creds.matches_url("http://localhost:3000/api/v1")
|
|
||||||
assert not creds.matches_url("http://localhost/simple")
|
|
||||||
|
|
||||||
def test_empty_headers_dict(self):
|
def test_empty_headers_dict(self):
|
||||||
"""Test HostScopedCredentials with empty headers."""
|
"""Test HostScopedCredentials with empty headers."""
|
||||||
creds = HostScopedCredentials(
|
creds = HostScopedCredentials(
|
||||||
@@ -141,20 +128,8 @@ class TestHostScopedCredentials:
|
|||||||
("*.example.com", "https://sub.api.example.com/test", True),
|
("*.example.com", "https://sub.api.example.com/test", True),
|
||||||
("*.example.com", "https://example.com/test", True),
|
("*.example.com", "https://example.com/test", True),
|
||||||
("*.example.com", "https://example.org/test", False),
|
("*.example.com", "https://example.org/test", False),
|
||||||
# Non-standard ports require explicit port in credential host
|
("localhost", "http://localhost:3000/test", True),
|
||||||
("localhost", "http://localhost:3000/test", False),
|
|
||||||
("localhost:3000", "http://localhost:3000/test", True),
|
|
||||||
("localhost", "http://127.0.0.1:3000/test", False),
|
("localhost", "http://127.0.0.1:3000/test", False),
|
||||||
# IPv6 addresses (frontend stores with brackets via URL.hostname)
|
|
||||||
("[::1]", "http://[::1]/test", True),
|
|
||||||
("[::1]", "http://[::1]:80/test", True),
|
|
||||||
("[::1]", "https://[::1]:443/test", True),
|
|
||||||
("[::1]", "http://[::1]:8080/test", False), # Non-standard port
|
|
||||||
("[::1]:8080", "http://[::1]:8080/test", True),
|
|
||||||
("[::1]:8080", "http://[::1]:9090/test", False),
|
|
||||||
("[2001:db8::1]", "http://[2001:db8::1]/path", True),
|
|
||||||
("[2001:db8::1]", "https://[2001:db8::1]:443/path", True),
|
|
||||||
("[2001:db8::1]", "http://[2001:db8::ff]/path", False),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool):
|
def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool):
|
||||||
|
|||||||
@@ -211,22 +211,6 @@ class AgentRejectionData(BaseNotificationData):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class WaitlistLaunchData(BaseNotificationData):
|
|
||||||
"""Notification data for when an agent from a waitlist is launched."""
|
|
||||||
|
|
||||||
agent_name: str
|
|
||||||
waitlist_name: str
|
|
||||||
store_url: str
|
|
||||||
launched_at: datetime
|
|
||||||
|
|
||||||
@field_validator("launched_at")
|
|
||||||
@classmethod
|
|
||||||
def validate_timezone(cls, value: datetime):
|
|
||||||
if value.tzinfo is None:
|
|
||||||
raise ValueError("datetime must have timezone information")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
NotificationData = Annotated[
|
NotificationData = Annotated[
|
||||||
Union[
|
Union[
|
||||||
AgentRunData,
|
AgentRunData,
|
||||||
@@ -239,7 +223,6 @@ NotificationData = Annotated[
|
|||||||
DailySummaryData,
|
DailySummaryData,
|
||||||
RefundRequestData,
|
RefundRequestData,
|
||||||
BaseSummaryData,
|
BaseSummaryData,
|
||||||
WaitlistLaunchData,
|
|
||||||
],
|
],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
@@ -290,7 +273,6 @@ def get_notif_data_type(
|
|||||||
NotificationType.REFUND_PROCESSED: RefundRequestData,
|
NotificationType.REFUND_PROCESSED: RefundRequestData,
|
||||||
NotificationType.AGENT_APPROVED: AgentApprovalData,
|
NotificationType.AGENT_APPROVED: AgentApprovalData,
|
||||||
NotificationType.AGENT_REJECTED: AgentRejectionData,
|
NotificationType.AGENT_REJECTED: AgentRejectionData,
|
||||||
NotificationType.WAITLIST_LAUNCH: WaitlistLaunchData,
|
|
||||||
}[notification_type]
|
}[notification_type]
|
||||||
|
|
||||||
|
|
||||||
@@ -336,7 +318,6 @@ class NotificationTypeOverride:
|
|||||||
NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
|
NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
|
||||||
NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE,
|
NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE,
|
||||||
NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE,
|
NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE,
|
||||||
NotificationType.WAITLIST_LAUNCH: QueueType.IMMEDIATE,
|
|
||||||
}
|
}
|
||||||
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
|
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
|
||||||
|
|
||||||
@@ -356,7 +337,6 @@ class NotificationTypeOverride:
|
|||||||
NotificationType.REFUND_PROCESSED: "refund_processed.html",
|
NotificationType.REFUND_PROCESSED: "refund_processed.html",
|
||||||
NotificationType.AGENT_APPROVED: "agent_approved.html",
|
NotificationType.AGENT_APPROVED: "agent_approved.html",
|
||||||
NotificationType.AGENT_REJECTED: "agent_rejected.html",
|
NotificationType.AGENT_REJECTED: "agent_rejected.html",
|
||||||
NotificationType.WAITLIST_LAUNCH: "waitlist_launch.html",
|
|
||||||
}[self.notification_type]
|
}[self.notification_type]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -374,7 +354,6 @@ class NotificationTypeOverride:
|
|||||||
NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed",
|
NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed",
|
||||||
NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!",
|
NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!",
|
||||||
NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates",
|
NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates",
|
||||||
NotificationType.WAITLIST_LAUNCH: "🚀 {{data.agent_name}} is now available!",
|
|
||||||
}[self.notification_type]
|
}[self.notification_type]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{# Waitlist Launch Notification Email Template #}
|
|
||||||
{#
|
|
||||||
Template variables:
|
|
||||||
data.agent_name: the name of the launched agent
|
|
||||||
data.waitlist_name: the name of the waitlist the user joined
|
|
||||||
data.store_url: URL to view the agent in the store
|
|
||||||
data.launched_at: when the agent was launched
|
|
||||||
|
|
||||||
Subject: {{ data.agent_name }} is now available!
|
|
||||||
#}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1 style="color: #7c3aed; font-size: 32px; font-weight: 700; margin: 0 0 24px 0; text-align: center;">
|
|
||||||
The wait is over!
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p style="color: #586069; font-size: 18px; text-align: center; margin: 0 0 24px 0;">
|
|
||||||
<strong>'{{ data.agent_name }}'</strong> is now live in the AutoGPT Store!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style="height: 32px; background: transparent;"></div>
|
|
||||||
|
|
||||||
<div style="background: #f3e8ff; border: 1px solid #d8b4fe; border-radius: 8px; padding: 20px; margin: 0;">
|
|
||||||
<h3 style="color: #6b21a8; font-size: 16px; font-weight: 600; margin: 0 0 12px 0;">
|
|
||||||
You're one of the first to know!
|
|
||||||
</h3>
|
|
||||||
<p style="color: #6b21a8; margin: 0; font-size: 16px; line-height: 1.5;">
|
|
||||||
You signed up for the <strong>{{ data.waitlist_name }}</strong> waitlist, and we're excited to let you know that this agent is now ready for you to use.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 32px; background: transparent;"></div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin: 24px 0;">
|
|
||||||
<a href="{{ data.store_url }}" style="display: inline-block; background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); color: white; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: 600; font-size: 16px;">
|
|
||||||
Get {{ data.agent_name }} Now
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 32px; background: transparent;"></div>
|
|
||||||
|
|
||||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; border-radius: 8px; padding: 20px; margin: 0;">
|
|
||||||
<h3 style="color: #0c5460; font-size: 16px; font-weight: 600; margin: 0 0 12px 0;">
|
|
||||||
What can you do now?
|
|
||||||
</h3>
|
|
||||||
<ul style="color: #0c5460; margin: 0; padding-left: 18px; font-size: 16px; line-height: 1.6;">
|
|
||||||
<li>Visit the store to learn more about what this agent can do</li>
|
|
||||||
<li>Install and start using the agent right away</li>
|
|
||||||
<li>Share it with others who might find it useful</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 32px; background: transparent;"></div>
|
|
||||||
|
|
||||||
<p style="color: #6a737d; font-size: 14px; text-align: center; margin: 24px 0;">
|
|
||||||
Thank you for helping us prioritize what to build! Your interest made this happen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -157,7 +157,12 @@ async def validate_url(
|
|||||||
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
||||||
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
||||||
"""
|
"""
|
||||||
parsed = parse_url(url)
|
# Canonicalize URL
|
||||||
|
url = url.strip("/ ").replace("\\", "/")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.scheme:
|
||||||
|
url = f"http://{url}"
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
# Check scheme
|
# Check scheme
|
||||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||||
@@ -215,17 +220,6 @@ async def validate_url(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_url(url: str) -> URL:
|
|
||||||
"""Canonicalizes and parses a URL string."""
|
|
||||||
url = url.strip("/ ").replace("\\", "/")
|
|
||||||
|
|
||||||
# Ensure scheme is present for proper parsing
|
|
||||||
if not re.match(r"[a-z0-9+.\-]+://", url):
|
|
||||||
url = f"http://{url}"
|
|
||||||
|
|
||||||
return urlparse(url)
|
|
||||||
|
|
||||||
|
|
||||||
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
||||||
"""
|
"""
|
||||||
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "WaitlistExternalStatus" AS ENUM ('DONE', 'NOT_STARTED', 'CANCELED', 'WORK_IN_PROGRESS');
|
|
||||||
|
|
||||||
-- AlterEnum
|
|
||||||
ALTER TYPE "NotificationType" ADD VALUE 'WAITLIST_LAUNCH';
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "WaitlistEntry" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"storeListingId" TEXT,
|
|
||||||
"owningUserId" TEXT NOT NULL,
|
|
||||||
"slug" TEXT NOT NULL,
|
|
||||||
"search" tsvector DEFAULT ''::tsvector,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"subHeading" TEXT NOT NULL,
|
|
||||||
"videoUrl" TEXT,
|
|
||||||
"agentOutputDemoUrl" TEXT,
|
|
||||||
"imageUrls" TEXT[],
|
|
||||||
"description" TEXT NOT NULL,
|
|
||||||
"categories" TEXT[],
|
|
||||||
"status" "WaitlistExternalStatus" NOT NULL DEFAULT 'NOT_STARTED',
|
|
||||||
"votes" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"unaffiliatedEmailUsers" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
|
||||||
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "_joinedWaitlists" (
|
|
||||||
"A" TEXT NOT NULL,
|
|
||||||
"B" TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "_joinedWaitlists_AB_unique" ON "_joinedWaitlists"("A", "B");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "_joinedWaitlists_B_index" ON "_joinedWaitlists"("B");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_B_fkey" FOREIGN KEY ("B") REFERENCES "WaitlistEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -70,10 +70,6 @@ model User {
|
|||||||
OAuthAuthorizationCodes OAuthAuthorizationCode[]
|
OAuthAuthorizationCodes OAuthAuthorizationCode[]
|
||||||
OAuthAccessTokens OAuthAccessToken[]
|
OAuthAccessTokens OAuthAccessToken[]
|
||||||
OAuthRefreshTokens OAuthRefreshToken[]
|
OAuthRefreshTokens OAuthRefreshToken[]
|
||||||
|
|
||||||
// Waitlist relations
|
|
||||||
waitlistEntries WaitlistEntry[]
|
|
||||||
joinedWaitlists WaitlistEntry[] @relation("joinedWaitlists")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OnboardingStep {
|
enum OnboardingStep {
|
||||||
@@ -348,7 +344,6 @@ enum NotificationType {
|
|||||||
REFUND_PROCESSED
|
REFUND_PROCESSED
|
||||||
AGENT_APPROVED
|
AGENT_APPROVED
|
||||||
AGENT_REJECTED
|
AGENT_REJECTED
|
||||||
WAITLIST_LAUNCH
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model NotificationEvent {
|
model NotificationEvent {
|
||||||
@@ -953,8 +948,7 @@ model StoreListing {
|
|||||||
OwningUser User @relation(fields: [owningUserId], references: [id])
|
OwningUser User @relation(fields: [owningUserId], references: [id])
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Versions StoreListingVersion[] @relation("ListingVersions")
|
Versions StoreListingVersion[] @relation("ListingVersions")
|
||||||
waitlistEntries WaitlistEntry[]
|
|
||||||
|
|
||||||
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
|
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
|
||||||
@@unique([agentGraphId])
|
@@unique([agentGraphId])
|
||||||
@@ -1086,47 +1080,6 @@ model StoreListingReview {
|
|||||||
@@index([reviewByUserId])
|
@@index([reviewByUserId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WaitlistExternalStatus {
|
|
||||||
DONE
|
|
||||||
NOT_STARTED
|
|
||||||
CANCELED
|
|
||||||
WORK_IN_PROGRESS
|
|
||||||
}
|
|
||||||
|
|
||||||
model WaitlistEntry {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
storeListingId String?
|
|
||||||
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
owningUserId String
|
|
||||||
OwningUser User @relation(fields: [owningUserId], references: [id])
|
|
||||||
|
|
||||||
slug String
|
|
||||||
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
|
|
||||||
|
|
||||||
// Content fields
|
|
||||||
name String
|
|
||||||
subHeading String
|
|
||||||
videoUrl String?
|
|
||||||
agentOutputDemoUrl String?
|
|
||||||
imageUrls String[]
|
|
||||||
description String
|
|
||||||
categories String[]
|
|
||||||
|
|
||||||
//Waitlist specific fields
|
|
||||||
status WaitlistExternalStatus @default(NOT_STARTED)
|
|
||||||
votes Int @default(0) // Hide from frontend api
|
|
||||||
joinedUsers User[] @relation("joinedWaitlists")
|
|
||||||
// NOTE: DO NOT DOUBLE SEND TO THESE USERS, IF THEY HAVE SIGNED UP SINCE THEY MAY HAVE ALREADY RECEIVED AN EMAIL
|
|
||||||
// DOUBLE CHECK WHEN SENDING THAT THEY ARE NOT IN THE JOINED USERS LIST ALSO
|
|
||||||
unaffiliatedEmailUsers String[] @default([])
|
|
||||||
|
|
||||||
isDeleted Boolean @default(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SubmissionStatus {
|
enum SubmissionStatus {
|
||||||
DRAFT // Being prepared, not yet submitted
|
DRAFT // Being prepared, not yet submitted
|
||||||
PENDING // Submitted, awaiting review
|
PENDING // Submitted, awaiting review
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
||||||
import { Users, DollarSign, UserSearch, FileText, Clock } from "lucide-react";
|
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
|
||||||
|
|
||||||
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
||||||
|
|
||||||
@@ -11,11 +11,6 @@ const sidebarLinkGroups = [
|
|||||||
href: "/admin/marketplace",
|
href: "/admin/marketplace",
|
||||||
icon: <Users className="h-6 w-6" />,
|
icon: <Users className="h-6 w-6" />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: "Waitlist Management",
|
|
||||||
href: "/admin/waitlist",
|
|
||||||
icon: <Clock className="h-6 w-6" />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: "User Spending",
|
text: "User Spending",
|
||||||
href: "/admin/spending",
|
href: "/admin/spending",
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import {
|
|
||||||
usePostV2CreateWaitlist,
|
|
||||||
getGetV2ListAllWaitlistsQueryKey,
|
|
||||||
} from "@/app/api/__generated__/endpoints/admin/admin";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import { Plus } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
export function CreateWaitlistButton() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const createWaitlistMutation = usePostV2CreateWaitlist({
|
|
||||||
mutation: {
|
|
||||||
onSuccess: (response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Waitlist created successfully",
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
setFormData({
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
subHeading: "",
|
|
||||||
description: "",
|
|
||||||
categories: "",
|
|
||||||
imageUrls: "",
|
|
||||||
videoUrl: "",
|
|
||||||
agentOutputDemoUrl: "",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListAllWaitlistsQueryKey(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to create waitlist",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Error creating waitlist:", error);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to create waitlist",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
subHeading: "",
|
|
||||||
description: "",
|
|
||||||
categories: "",
|
|
||||||
imageUrls: "",
|
|
||||||
videoUrl: "",
|
|
||||||
agentOutputDemoUrl: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInputChange(id: string, value: string) {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSlug(name: string) {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
createWaitlistMutation.mutate({
|
|
||||||
data: {
|
|
||||||
name: formData.name,
|
|
||||||
slug: formData.slug || generateSlug(formData.name),
|
|
||||||
subHeading: formData.subHeading,
|
|
||||||
description: formData.description,
|
|
||||||
categories: formData.categories
|
|
||||||
? formData.categories.split(",").map((c) => c.trim())
|
|
||||||
: [],
|
|
||||||
imageUrls: formData.imageUrls
|
|
||||||
? formData.imageUrls.split(",").map((u) => u.trim())
|
|
||||||
: [],
|
|
||||||
videoUrl: formData.videoUrl || null,
|
|
||||||
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => setOpen(true)}>
|
|
||||||
<Plus size={16} className="mr-2" />
|
|
||||||
Create Waitlist
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
title="Create New Waitlist"
|
|
||||||
controlled={{
|
|
||||||
isOpen: open,
|
|
||||||
set: async (isOpen) => setOpen(isOpen),
|
|
||||||
}}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
styling={{ maxWidth: "600px" }}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<p className="mb-4 text-sm text-zinc-500">
|
|
||||||
Create a new waitlist for an upcoming agent. Users can sign up to be
|
|
||||||
notified when it launches.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
label="Name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
|
||||||
placeholder="SEO Analysis Agent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="slug"
|
|
||||||
label="Slug"
|
|
||||||
value={formData.slug}
|
|
||||||
onChange={(e) => handleInputChange("slug", e.target.value)}
|
|
||||||
placeholder="seo-analysis-agent (auto-generated if empty)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="subHeading"
|
|
||||||
label="Subheading"
|
|
||||||
value={formData.subHeading}
|
|
||||||
onChange={(e) => handleInputChange("subHeading", e.target.value)}
|
|
||||||
placeholder="Analyze your website's SEO in minutes"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
label="Description"
|
|
||||||
type="textarea"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
|
||||||
placeholder="Detailed description of what this agent does..."
|
|
||||||
rows={4}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="categories"
|
|
||||||
label="Categories (comma-separated)"
|
|
||||||
value={formData.categories}
|
|
||||||
onChange={(e) => handleInputChange("categories", e.target.value)}
|
|
||||||
placeholder="SEO, Marketing, Analysis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="imageUrls"
|
|
||||||
label="Image URLs (comma-separated)"
|
|
||||||
value={formData.imageUrls}
|
|
||||||
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
|
|
||||||
placeholder="https://example.com/image1.jpg, https://example.com/image2.jpg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="videoUrl"
|
|
||||||
label="Video URL (optional)"
|
|
||||||
value={formData.videoUrl}
|
|
||||||
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
|
|
||||||
placeholder="https://youtube.com/watch?v=..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="agentOutputDemoUrl"
|
|
||||||
label="Output Demo URL (optional)"
|
|
||||||
value={formData.agentOutputDemoUrl}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange("agentOutputDemoUrl", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="https://example.com/demo-output.mp4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={createWaitlistMutation.isPending}>
|
|
||||||
Create Waitlist
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</form>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
|
||||||
import { Select } from "@/components/atoms/Select/Select";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import { usePutV2UpdateWaitlist } from "@/app/api/__generated__/endpoints/admin/admin";
|
|
||||||
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
|
|
||||||
import type { WaitlistUpdateRequest } from "@/app/api/__generated__/models/waitlistUpdateRequest";
|
|
||||||
import { WaitlistExternalStatus } from "@/app/api/__generated__/models/waitlistExternalStatus";
|
|
||||||
|
|
||||||
type EditWaitlistDialogProps = {
|
|
||||||
waitlist: WaitlistAdminResponse;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: WaitlistExternalStatus.NOT_STARTED, label: "Not Started" },
|
|
||||||
{ value: WaitlistExternalStatus.WORK_IN_PROGRESS, label: "Work In Progress" },
|
|
||||||
{ value: WaitlistExternalStatus.DONE, label: "Done" },
|
|
||||||
{ value: WaitlistExternalStatus.CANCELED, label: "Canceled" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function EditWaitlistDialog({
|
|
||||||
waitlist,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
}: EditWaitlistDialogProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const updateWaitlistMutation = usePutV2UpdateWaitlist();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: waitlist.name,
|
|
||||||
slug: waitlist.slug,
|
|
||||||
subHeading: waitlist.subHeading,
|
|
||||||
description: waitlist.description,
|
|
||||||
categories: waitlist.categories.join(", "),
|
|
||||||
imageUrls: waitlist.imageUrls.join(", "),
|
|
||||||
videoUrl: waitlist.videoUrl || "",
|
|
||||||
agentOutputDemoUrl: waitlist.agentOutputDemoUrl || "",
|
|
||||||
status: waitlist.status,
|
|
||||||
storeListingId: waitlist.storeListingId || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInputChange(id: string, value: string) {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStatusChange(value: string) {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: value as WaitlistExternalStatus,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const updateData: WaitlistUpdateRequest = {
|
|
||||||
name: formData.name,
|
|
||||||
slug: formData.slug,
|
|
||||||
subHeading: formData.subHeading,
|
|
||||||
description: formData.description,
|
|
||||||
categories: formData.categories
|
|
||||||
? formData.categories.split(",").map((c) => c.trim())
|
|
||||||
: [],
|
|
||||||
imageUrls: formData.imageUrls
|
|
||||||
? formData.imageUrls.split(",").map((u) => u.trim())
|
|
||||||
: [],
|
|
||||||
videoUrl: formData.videoUrl || null,
|
|
||||||
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
|
|
||||||
status: formData.status,
|
|
||||||
storeListingId: formData.storeListingId || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
updateWaitlistMutation.mutate(
|
|
||||||
{ waitlistId: waitlist.id, data: updateData },
|
|
||||||
{
|
|
||||||
onSuccess: (response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Waitlist updated successfully",
|
|
||||||
});
|
|
||||||
onSave();
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to update waitlist",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to update waitlist",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title="Edit Waitlist"
|
|
||||||
controlled={{
|
|
||||||
isOpen: true,
|
|
||||||
set: async (open) => {
|
|
||||||
if (!open) onClose();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
styling={{ maxWidth: "600px" }}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<p className="mb-4 text-sm text-zinc-500">
|
|
||||||
Update the waitlist details. Changes will be reflected immediately.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
label="Name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="slug"
|
|
||||||
label="Slug"
|
|
||||||
value={formData.slug}
|
|
||||||
onChange={(e) => handleInputChange("slug", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="subHeading"
|
|
||||||
label="Subheading"
|
|
||||||
value={formData.subHeading}
|
|
||||||
onChange={(e) => handleInputChange("subHeading", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
label="Description"
|
|
||||||
type="textarea"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
id="status"
|
|
||||||
label="Status"
|
|
||||||
value={formData.status}
|
|
||||||
onValueChange={handleStatusChange}
|
|
||||||
options={STATUS_OPTIONS}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="categories"
|
|
||||||
label="Categories (comma-separated)"
|
|
||||||
value={formData.categories}
|
|
||||||
onChange={(e) => handleInputChange("categories", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="imageUrls"
|
|
||||||
label="Image URLs (comma-separated)"
|
|
||||||
value={formData.imageUrls}
|
|
||||||
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="videoUrl"
|
|
||||||
label="Video URL"
|
|
||||||
value={formData.videoUrl}
|
|
||||||
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="agentOutputDemoUrl"
|
|
||||||
label="Output Demo URL"
|
|
||||||
value={formData.agentOutputDemoUrl}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange("agentOutputDemoUrl", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="storeListingId"
|
|
||||||
label="Store Listing ID (for linking)"
|
|
||||||
value={formData.storeListingId}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange("storeListingId", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Leave empty if not linked"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button type="button" variant="secondary" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={updateWaitlistMutation.isPending}>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</form>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { User, Envelope, DownloadSimple } from "@phosphor-icons/react";
|
|
||||||
import { useGetV2GetWaitlistSignups } from "@/app/api/__generated__/endpoints/admin/admin";
|
|
||||||
|
|
||||||
type WaitlistSignupsDialogProps = {
|
|
||||||
waitlistId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WaitlistSignupsDialog({
|
|
||||||
waitlistId,
|
|
||||||
onClose,
|
|
||||||
}: WaitlistSignupsDialogProps) {
|
|
||||||
const {
|
|
||||||
data: signupsResponse,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useGetV2GetWaitlistSignups(waitlistId);
|
|
||||||
|
|
||||||
const signups = signupsResponse?.status === 200 ? signupsResponse.data : null;
|
|
||||||
|
|
||||||
function exportToCSV() {
|
|
||||||
if (!signups) return;
|
|
||||||
|
|
||||||
const headers = ["Type", "Email", "User ID", "Username"];
|
|
||||||
const rows = signups.signups.map((signup) => [
|
|
||||||
signup.type,
|
|
||||||
signup.email || "",
|
|
||||||
signup.userId || "",
|
|
||||||
signup.username || "",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const escapeCell = (cell: string) => `"${cell.replace(/"/g, '""')}"`;
|
|
||||||
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(","),
|
|
||||||
...rows.map((row) => row.map(escapeCell).join(",")),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `waitlist-${waitlistId}-signups.csv`;
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContent() {
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="py-10 text-center">Loading signups...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="py-10 text-center text-red-500">
|
|
||||||
Failed to load signups. Please try again.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signups || signups.signups.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-10 text-center text-gray-500">
|
|
||||||
No signups yet for this waitlist.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="secondary" size="small" onClick={exportToCSV}>
|
|
||||||
<DownloadSimple className="mr-2 h-4 w-4" size={16} />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[400px] overflow-y-auto rounded-md border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
|
||||||
Email / Username
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
|
||||||
User ID
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{signups.signups.map((signup, index) => (
|
|
||||||
<tr key={index}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{signup.type === "user" ? (
|
|
||||||
<span className="flex items-center gap-1 text-blue-600">
|
|
||||||
<User className="h-4 w-4" size={16} /> User
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-1 text-gray-600">
|
|
||||||
<Envelope className="h-4 w-4" size={16} /> Email
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{signup.type === "user"
|
|
||||||
? signup.username || signup.email
|
|
||||||
: signup.email}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-sm">
|
|
||||||
{signup.userId || "-"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title="Waitlist Signups"
|
|
||||||
controlled={{
|
|
||||||
isOpen: true,
|
|
||||||
set: async (open) => {
|
|
||||||
if (!open) onClose();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
styling={{ maxWidth: "700px" }}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<p className="mb-4 text-sm text-zinc-500">
|
|
||||||
{signups
|
|
||||||
? `${signups.totalCount} total signups`
|
|
||||||
: "Loading signups..."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{renderContent()}
|
|
||||||
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button variant="secondary" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/__legacy__/ui/table";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import {
|
|
||||||
useGetV2ListAllWaitlists,
|
|
||||||
useDeleteV2DeleteWaitlist,
|
|
||||||
getGetV2ListAllWaitlistsQueryKey,
|
|
||||||
} from "@/app/api/__generated__/endpoints/admin/admin";
|
|
||||||
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
|
|
||||||
import { EditWaitlistDialog } from "./EditWaitlistDialog";
|
|
||||||
import { WaitlistSignupsDialog } from "./WaitlistSignupsDialog";
|
|
||||||
import { Trash, PencilSimple, Users, Link } from "@phosphor-icons/react";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
|
|
||||||
export function WaitlistTable() {
|
|
||||||
const [editingWaitlist, setEditingWaitlist] =
|
|
||||||
useState<WaitlistAdminResponse | null>(null);
|
|
||||||
const [viewingSignups, setViewingSignups] = useState<string | null>(null);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: response, isLoading, error } = useGetV2ListAllWaitlists();
|
|
||||||
|
|
||||||
const deleteWaitlistMutation = useDeleteV2DeleteWaitlist({
|
|
||||||
mutation: {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Waitlist deleted successfully",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListAllWaitlistsQueryKey(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Error deleting waitlist:", error);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to delete waitlist",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleDelete(waitlistId: string) {
|
|
||||||
if (!confirm("Are you sure you want to delete this waitlist?")) return;
|
|
||||||
deleteWaitlistMutation.mutate({ waitlistId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWaitlistSaved() {
|
|
||||||
setEditingWaitlist(null);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListAllWaitlistsQueryKey(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStatus(status: string) {
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
NOT_STARTED: "bg-gray-100 text-gray-800",
|
|
||||||
WORK_IN_PROGRESS: "bg-blue-100 text-blue-800",
|
|
||||||
DONE: "bg-green-100 text-green-800",
|
|
||||||
CANCELED: "bg-red-100 text-red-800",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${statusColors[status] || "bg-gray-100 text-gray-700"}`}
|
|
||||||
>
|
|
||||||
{status.replace(/_/g, " ")}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
|
||||||
if (!dateStr) return "-";
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date(dateStr));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="py-10 text-center">Loading waitlists...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="py-10 text-center text-red-500">
|
|
||||||
Error loading waitlists. Please try again.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitlists = response?.status === 200 ? response.data.waitlists : [];
|
|
||||||
|
|
||||||
if (waitlists.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-10 text-center text-gray-500">
|
|
||||||
No waitlists found. Create one to get started!
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="rounded-md border bg-white">
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="bg-gray-50">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="font-medium">Name</TableHead>
|
|
||||||
<TableHead className="font-medium">Status</TableHead>
|
|
||||||
<TableHead className="font-medium">Signups</TableHead>
|
|
||||||
<TableHead className="font-medium">Votes</TableHead>
|
|
||||||
<TableHead className="font-medium">Created</TableHead>
|
|
||||||
<TableHead className="font-medium">Linked Agent</TableHead>
|
|
||||||
<TableHead className="font-medium">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{waitlists.map((waitlist) => (
|
|
||||||
<TableRow key={waitlist.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{waitlist.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{waitlist.subHeading}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatStatus(waitlist.status)}</TableCell>
|
|
||||||
<TableCell>{waitlist.signupCount}</TableCell>
|
|
||||||
<TableCell>{waitlist.votes}</TableCell>
|
|
||||||
<TableCell>{formatDate(waitlist.createdAt)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{waitlist.storeListingId ? (
|
|
||||||
<span className="text-green-600">
|
|
||||||
<Link size={16} className="inline" /> Linked
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">Not linked</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
onClick={() => setViewingSignups(waitlist.id)}
|
|
||||||
title="View signups"
|
|
||||||
>
|
|
||||||
<Users size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
onClick={() => setEditingWaitlist(waitlist)}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<PencilSimple size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleDelete(waitlist.id)}
|
|
||||||
title="Delete"
|
|
||||||
disabled={deleteWaitlistMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash size={16} className="text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editingWaitlist && (
|
|
||||||
<EditWaitlistDialog
|
|
||||||
waitlist={editingWaitlist}
|
|
||||||
onClose={() => setEditingWaitlist(null)}
|
|
||||||
onSave={handleWaitlistSaved}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewingSignups && (
|
|
||||||
<WaitlistSignupsDialog
|
|
||||||
waitlistId={viewingSignups}
|
|
||||||
onClose={() => setViewingSignups(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { withRoleAccess } from "@/lib/withRoleAccess";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { WaitlistTable } from "./components/WaitlistTable";
|
|
||||||
import { CreateWaitlistButton } from "./components/CreateWaitlistButton";
|
|
||||||
import { Warning } from "@phosphor-icons/react/dist/ssr";
|
|
||||||
|
|
||||||
function WaitlistDashboard() {
|
|
||||||
return (
|
|
||||||
<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">Waitlist Management</h1>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
Manage upcoming agent waitlists and track signups
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CreateWaitlistButton />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950">
|
|
||||||
<Warning
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400"
|
|
||||||
weight="fill"
|
|
||||||
/>
|
|
||||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
|
||||||
<p className="font-medium">TODO: Email-only signup notifications</p>
|
|
||||||
<p className="mt-1 text-amber-700 dark:text-amber-300">
|
|
||||||
Notifications for email-only signups (users who weren't
|
|
||||||
logged in) have not been implemented yet. Currently only
|
|
||||||
registered users will receive launch emails.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="py-10 text-center">Loading waitlists...</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<WaitlistTable />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function WaitlistDashboardPage() {
|
|
||||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
|
||||||
const ProtectedWaitlistDashboard = await withAdminAccess(WaitlistDashboard);
|
|
||||||
return <ProtectedWaitlistDashboard />;
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,6 @@
|
|||||||
import { OAuthPopupResultMessage } from "./types";
|
import { OAuthPopupResultMessage } from "./types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely encode a value as JSON for embedding in a script tag.
|
|
||||||
* Escapes characters that could break out of the script context to prevent XSS.
|
|
||||||
*/
|
|
||||||
function safeJsonStringify(value: unknown): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
.replace(/</g, "\\u003c")
|
|
||||||
.replace(/>/g, "\\u003e")
|
|
||||||
.replace(/&/g, "\\u0026");
|
|
||||||
}
|
|
||||||
|
|
||||||
// This route is intended to be used as the callback for integration OAuth flows,
|
// This route is intended to be used as the callback for integration OAuth flows,
|
||||||
// controlled by the CredentialsInput component. The CredentialsInput opens the login
|
// controlled by the CredentialsInput component. The CredentialsInput opens the login
|
||||||
// page in a pop-up window, which then redirects to this route to close the loop.
|
// page in a pop-up window, which then redirects to this route to close the loop.
|
||||||
@@ -34,13 +23,12 @@ export async function GET(request: Request) {
|
|||||||
console.debug("Sending message to opener:", message);
|
console.debug("Sending message to opener:", message);
|
||||||
|
|
||||||
// Return a response with the message as JSON and a script to close the window
|
// Return a response with the message as JSON and a script to close the window
|
||||||
// Use safeJsonStringify to prevent XSS by escaping <, >, and & characters
|
|
||||||
return new NextResponse(
|
return new NextResponse(
|
||||||
`
|
`
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
window.opener.postMessage(${safeJsonStringify(message)});
|
window.opener.postMessage(${JSON.stringify(message)});
|
||||||
window.close();
|
window.close();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -26,20 +26,8 @@ export function buildCopilotChatUrl(prompt: string): string {
|
|||||||
|
|
||||||
export function getQuickActions(): string[] {
|
export function getQuickActions(): string[] {
|
||||||
return [
|
return [
|
||||||
"I don't know where to start, just ask me stuff",
|
"Show me what I can automate",
|
||||||
"I do the same thing every week and it's killing me",
|
"Design a custom workflow",
|
||||||
"Help me find where I'm wasting my time",
|
"Help me with content creation",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInputPlaceholder(width?: number) {
|
|
||||||
if (!width) return "What's your role and what eats up most of your day?";
|
|
||||||
|
|
||||||
if (width < 500) {
|
|
||||||
return "I'm a chef and I hate...";
|
|
||||||
}
|
|
||||||
if (width <= 1080) {
|
|
||||||
return "What's your role and what eats up most of your day?";
|
|
||||||
}
|
|
||||||
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { Chat } from "@/components/contextual/Chat/Chat";
|
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useCopilotStore } from "./copilot-page-store";
|
import { useCopilotStore } from "./copilot-page-store";
|
||||||
import { getInputPlaceholder } from "./helpers";
|
|
||||||
import { useCopilotPage } from "./useCopilotPage";
|
import { useCopilotPage } from "./useCopilotPage";
|
||||||
|
|
||||||
export default function CopilotPage() {
|
export default function CopilotPage() {
|
||||||
@@ -16,25 +14,8 @@ export default function CopilotPage() {
|
|||||||
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
||||||
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
||||||
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
||||||
|
|
||||||
const [inputPlaceholder, setInputPlaceholder] = useState(
|
|
||||||
getInputPlaceholder(),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleResize();
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
|
const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
|
||||||
state;
|
state;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleQuickAction,
|
handleQuickAction,
|
||||||
startChatWithPrompt,
|
startChatWithPrompt,
|
||||||
@@ -92,7 +73,7 @@ export default function CopilotPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-3 py-5 md:px-6 md:py-10">
|
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
||||||
<div className="w-full text-center">
|
<div className="w-full text-center">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
@@ -109,25 +90,25 @@ export default function CopilotPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
<Text
|
<Text
|
||||||
variant="h3"
|
variant="h3"
|
||||||
className="mb-1 !text-[1.375rem] text-zinc-700"
|
className="mb-3 !text-[1.375rem] text-zinc-700"
|
||||||
>
|
>
|
||||||
Hey, <span className="text-violet-600">{greetingName}</span>
|
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h3" className="mb-8 !font-normal">
|
<Text variant="h3" className="mb-8 !font-normal">
|
||||||
Tell me about your work — I'll find what to automate.
|
What do you want to automate?
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={startChatWithPrompt}
|
onSend={startChatWithPrompt}
|
||||||
placeholder={inputPlaceholder}
|
placeholder='You can search or just ask - e.g. "create a blog post outline"'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex flex-nowrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{quickActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action}
|
key={action}
|
||||||
@@ -135,7 +116,7 @@ export default function CopilotPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleQuickAction(action)}
|
onClick={() => handleQuickAction(action)}
|
||||||
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
|
className="h-auto shrink-0 border-zinc-600 !px-4 !py-2 text-[1rem] text-zinc-600"
|
||||||
>
|
>
|
||||||
{action}
|
{action}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useMainMarketplacePage } from "./useMainMarketplacePage";
|
|||||||
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
|
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
|
||||||
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
|
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
|
||||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||||
import { WaitlistSection } from "../WaitlistSection/WaitlistSection";
|
|
||||||
|
|
||||||
export const MainMarkeplacePage = () => {
|
export const MainMarkeplacePage = () => {
|
||||||
const { featuredAgents, topAgents, featuredCreators, isLoading, hasError } =
|
const { featuredAgents, topAgents, featuredCreators, isLoading, hasError } =
|
||||||
@@ -47,10 +46,6 @@ export const MainMarkeplacePage = () => {
|
|||||||
{/* 100px margin because our featured sections button are placed 40px below the container */}
|
{/* 100px margin because our featured sections button are placed 40px below the container */}
|
||||||
<Separator className="mb-6 mt-24" />
|
<Separator className="mb-6 mt-24" />
|
||||||
|
|
||||||
{/* Waitlist Section - "Help Shape What's Next" */}
|
|
||||||
<WaitlistSection />
|
|
||||||
<Separator className="mb-6 mt-12" />
|
|
||||||
|
|
||||||
{topAgents && (
|
{topAgents && (
|
||||||
<AgentsSection sectionTitle="Top Agents" agents={topAgents.agents} />
|
<AgentsSection sectionTitle="Top Agents" agents={topAgents.agents} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Check } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
interface WaitlistCardProps {
|
|
||||||
name: string;
|
|
||||||
subHeading: string;
|
|
||||||
description: string;
|
|
||||||
imageUrl: string | null;
|
|
||||||
isMember?: boolean;
|
|
||||||
onCardClick: () => void;
|
|
||||||
onJoinClick: (e: React.MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WaitlistCard({
|
|
||||||
name,
|
|
||||||
subHeading,
|
|
||||||
description,
|
|
||||||
imageUrl,
|
|
||||||
isMember = false,
|
|
||||||
onCardClick,
|
|
||||||
onJoinClick,
|
|
||||||
}: WaitlistCardProps) {
|
|
||||||
function handleJoinClick(e: React.MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
onJoinClick(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-[24rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-white transition-all duration-300 hover:shadow-lg dark:bg-zinc-900 dark:hover:shadow-gray-700"
|
|
||||||
onClick={onCardClick}
|
|
||||||
data-testid="waitlist-card"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${name} waitlist card`}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
onCardClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Image Section */}
|
|
||||||
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-large md:aspect-[2.17/1]">
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
src={imageUrl}
|
|
||||||
alt={`${name} preview image`}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800">
|
|
||||||
<span className="text-4xl font-bold text-neutral-400 dark:text-neutral-500">
|
|
||||||
{name.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex w-full flex-1 flex-col px-4">
|
|
||||||
{/* Name and Subheading */}
|
|
||||||
<div className="flex w-full flex-col">
|
|
||||||
<h3 className="line-clamp-1 font-poppins text-xl font-semibold text-[#272727] dark:text-neutral-100">
|
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 line-clamp-1 text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
{subHeading}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="mt-2 flex w-full flex-col">
|
|
||||||
<p className="line-clamp-5 text-sm font-normal leading-relaxed text-neutral-600 dark:text-neutral-400">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-grow" />
|
|
||||||
|
|
||||||
{/* Join Waitlist Button */}
|
|
||||||
<div className="mt-4 w-full pb-4">
|
|
||||||
{isMember ? (
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
className="w-full rounded-full bg-green-600 text-white hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-700"
|
|
||||||
>
|
|
||||||
<Check className="mr-2" size={16} weight="bold" />
|
|
||||||
On the waitlist
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={handleJoinClick}
|
|
||||||
className="w-full rounded-full bg-zinc-800 text-white hover:bg-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-600"
|
|
||||||
>
|
|
||||||
Join waitlist
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
CarouselNext,
|
|
||||||
CarouselPrevious,
|
|
||||||
} from "@/components/__legacy__/ui/carousel";
|
|
||||||
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
|
|
||||||
import { Check, Play } from "@phosphor-icons/react";
|
|
||||||
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import { usePostV2AddSelfToTheAgentWaitlist } from "@/app/api/__generated__/endpoints/store/store";
|
|
||||||
|
|
||||||
interface MediaItem {
|
|
||||||
type: "image" | "video";
|
|
||||||
url: string;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract YouTube video ID from various URL formats
|
|
||||||
function getYouTubeVideoId(url: string): string | null {
|
|
||||||
const regExp =
|
|
||||||
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
|
|
||||||
const match = url.match(regExp);
|
|
||||||
return match && match[7].length === 11 ? match[7] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate video URL for security
|
|
||||||
function isValidVideoUrl(url: string): boolean {
|
|
||||||
if (url.startsWith("data:video")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const videoExtensions = /\.(mp4|webm|ogg)$/i;
|
|
||||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
|
||||||
const validUrl = /^(https?:\/\/)/i;
|
|
||||||
const cleanedUrl = url.split("?")[0];
|
|
||||||
return (
|
|
||||||
(validUrl.test(url) && videoExtensions.test(cleanedUrl)) ||
|
|
||||||
youtubeRegex.test(url)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Video player with YouTube embed support
|
|
||||||
function VideoPlayer({
|
|
||||||
url,
|
|
||||||
autoPlay = false,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
autoPlay?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const youtubeId = getYouTubeVideoId(url);
|
|
||||||
|
|
||||||
if (youtubeId) {
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${youtubeId}${autoPlay ? "?autoplay=1" : ""}`}
|
|
||||||
title="YouTube video player"
|
|
||||||
className={className}
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
sandbox="allow-same-origin allow-scripts allow-presentation"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidVideoUrl(url)) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-center bg-zinc-800 ${className}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-zinc-400">Invalid video URL</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <video src={url} controls autoPlay={autoPlay} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaCarousel({ waitlist }: { waitlist: StoreWaitlistEntry }) {
|
|
||||||
const [activeVideo, setActiveVideo] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Build media items array: videos first, then images
|
|
||||||
const mediaItems: MediaItem[] = [
|
|
||||||
...(waitlist.videoUrl
|
|
||||||
? [{ type: "video" as const, url: waitlist.videoUrl, label: "Video" }]
|
|
||||||
: []),
|
|
||||||
...(waitlist.agentOutputDemoUrl
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: "video" as const,
|
|
||||||
url: waitlist.agentOutputDemoUrl,
|
|
||||||
label: "Demo",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...waitlist.imageUrls.map((url) => ({ type: "image" as const, url })),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (mediaItems.length === 0) return null;
|
|
||||||
|
|
||||||
// Single item - no carousel needed
|
|
||||||
if (mediaItems.length === 1) {
|
|
||||||
const item = mediaItems[0];
|
|
||||||
return (
|
|
||||||
<div className="relative aspect-[350/196] w-full overflow-hidden rounded-large">
|
|
||||||
{item.type === "image" ? (
|
|
||||||
<Image
|
|
||||||
src={item.url}
|
|
||||||
alt={`${waitlist.name} preview`}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VideoPlayer url={item.url} className="h-full w-full object-cover" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple items - use carousel
|
|
||||||
return (
|
|
||||||
<Carousel className="w-full">
|
|
||||||
<CarouselContent>
|
|
||||||
{mediaItems.map((item, index) => (
|
|
||||||
<CarouselItem key={index}>
|
|
||||||
<div className="relative aspect-[350/196] w-full overflow-hidden rounded-large">
|
|
||||||
{item.type === "image" ? (
|
|
||||||
<Image
|
|
||||||
src={item.url}
|
|
||||||
alt={`${waitlist.name} preview ${index + 1}`}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : activeVideo === item.url ? (
|
|
||||||
<VideoPlayer
|
|
||||||
url={item.url}
|
|
||||||
autoPlay
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveVideo(item.url)}
|
|
||||||
className="group relative h-full w-full bg-zinc-900"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 transition-transform group-hover:scale-110">
|
|
||||||
<Play size={32} weight="fill" className="text-zinc-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="absolute bottom-3 left-3 text-sm text-white">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
<CarouselPrevious className="left-2 top-1/2 -translate-y-1/2" />
|
|
||||||
<CarouselNext className="right-2 top-1/2 -translate-y-1/2" />
|
|
||||||
</Carousel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WaitlistDetailModalProps {
|
|
||||||
waitlist: StoreWaitlistEntry;
|
|
||||||
isMember?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onJoinSuccess?: (waitlistId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WaitlistDetailModal({
|
|
||||||
waitlist,
|
|
||||||
isMember = false,
|
|
||||||
onClose,
|
|
||||||
onJoinSuccess,
|
|
||||||
}: WaitlistDetailModalProps) {
|
|
||||||
const { user } = useSupabaseStore();
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const joinWaitlistMutation = usePostV2AddSelfToTheAgentWaitlist();
|
|
||||||
|
|
||||||
function handleJoin() {
|
|
||||||
joinWaitlistMutation.mutate(
|
|
||||||
{
|
|
||||||
waitlistId: waitlist.waitlistId,
|
|
||||||
data: { email: user ? undefined : email },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: (response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
setSuccess(true);
|
|
||||||
toast({
|
|
||||||
title: "You're on the waitlist!",
|
|
||||||
description: `We'll notify you when ${waitlist.name} goes live.`,
|
|
||||||
});
|
|
||||||
onJoinSuccess?.(waitlist.waitlistId);
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to join waitlist. Please try again.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to join waitlist. Please try again.",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success state
|
|
||||||
if (success) {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title=""
|
|
||||||
controlled={{
|
|
||||||
isOpen: true,
|
|
||||||
set: async (open) => {
|
|
||||||
if (!open) onClose();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
styling={{ maxWidth: "500px" }}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<div className="flex flex-col items-center justify-center py-4 text-center">
|
|
||||||
{/* Party emoji */}
|
|
||||||
<span className="mb-2 text-5xl">🎉</span>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 className="mb-2 font-poppins text-[22px] font-medium leading-7 text-zinc-900 dark:text-zinc-100">
|
|
||||||
You're on the waitlist
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<p className="text-base leading-[26px] text-zinc-600 dark:text-zinc-400">
|
|
||||||
Thanks for helping us prioritize which agents to build next.
|
|
||||||
We'll notify you when this agent goes live in the
|
|
||||||
marketplace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<Dialog.Footer className="flex justify-center pb-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full border border-zinc-700 bg-white px-4 py-3 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main modal - handles both member and non-member states
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title="Join the waitlist"
|
|
||||||
controlled={{
|
|
||||||
isOpen: true,
|
|
||||||
set: async (open) => {
|
|
||||||
if (!open) onClose();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
styling={{ maxWidth: "500px" }}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
{/* Subtitle */}
|
|
||||||
<p className="mb-6 text-center text-base text-zinc-600 dark:text-zinc-400">
|
|
||||||
Help us decide what to build next — and get notified when this agent
|
|
||||||
is ready
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Media Carousel */}
|
|
||||||
<MediaCarousel waitlist={waitlist} />
|
|
||||||
|
|
||||||
{/* Agent Name */}
|
|
||||||
<h3 className="mt-4 font-poppins text-[22px] font-medium leading-7 text-zinc-800 dark:text-zinc-100">
|
|
||||||
{waitlist.name}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Agent Description */}
|
|
||||||
<p className="mt-2 line-clamp-5 text-sm leading-[22px] text-zinc-500 dark:text-zinc-400">
|
|
||||||
{waitlist.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Email input for non-logged-in users who haven't joined */}
|
|
||||||
{!isMember && !user && (
|
|
||||||
<div className="mt-4 pr-1">
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
label="Email address"
|
|
||||||
type="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer buttons */}
|
|
||||||
<Dialog.Footer className="sticky bottom-0 mt-6 flex justify-center gap-3 bg-white pb-2 pt-4 dark:bg-zinc-900">
|
|
||||||
{isMember ? (
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
className="rounded-full bg-green-600 px-4 py-3 text-white hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-700"
|
|
||||||
>
|
|
||||||
<Check size={16} className="mr-2" />
|
|
||||||
You're on the waitlist
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
onClick={handleJoin}
|
|
||||||
loading={joinWaitlistMutation.isPending}
|
|
||||||
disabled={!user && !email}
|
|
||||||
className="rounded-full bg-zinc-800 px-4 py-3 text-white hover:bg-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-600"
|
|
||||||
>
|
|
||||||
Join waitlist
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full bg-zinc-200 px-4 py-3 text-zinc-900 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-100 dark:hover:bg-zinc-600"
|
|
||||||
>
|
|
||||||
Not now
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
} from "@/components/__legacy__/ui/carousel";
|
|
||||||
import { WaitlistCard } from "../WaitlistCard/WaitlistCard";
|
|
||||||
import { WaitlistDetailModal } from "../WaitlistDetailModal/WaitlistDetailModal";
|
|
||||||
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
|
|
||||||
import { useWaitlistSection } from "./useWaitlistSection";
|
|
||||||
|
|
||||||
export function WaitlistSection() {
|
|
||||||
const { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined } =
|
|
||||||
useWaitlistSection();
|
|
||||||
const [selectedWaitlist, setSelectedWaitlist] =
|
|
||||||
useState<StoreWaitlistEntry | null>(null);
|
|
||||||
|
|
||||||
function handleOpenModal(waitlist: StoreWaitlistEntry) {
|
|
||||||
setSelectedWaitlist(waitlist);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleJoinSuccess(waitlistId: string) {
|
|
||||||
markAsJoined(waitlistId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't render if loading, error, or no waitlists
|
|
||||||
if (isLoading || hasError || !waitlists || waitlists.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<div className="w-full max-w-[1360px]">
|
|
||||||
{/* Section Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="font-poppins text-2xl font-semibold text-[#282828] dark:text-neutral-200">
|
|
||||||
Help Shape What's Next
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-base text-neutral-600 dark:text-neutral-400">
|
|
||||||
These agents are in development. Your interest helps us prioritize
|
|
||||||
what gets built — and we'll notify you when they're ready.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Carousel View */}
|
|
||||||
<Carousel
|
|
||||||
className="md:hidden"
|
|
||||||
opts={{
|
|
||||||
loop: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CarouselContent>
|
|
||||||
{waitlists.map((waitlist) => (
|
|
||||||
<CarouselItem
|
|
||||||
key={waitlist.waitlistId}
|
|
||||||
className="min-w-64 max-w-71"
|
|
||||||
>
|
|
||||||
<WaitlistCard
|
|
||||||
name={waitlist.name}
|
|
||||||
subHeading={waitlist.subHeading}
|
|
||||||
description={waitlist.description}
|
|
||||||
imageUrl={waitlist.imageUrls[0] || null}
|
|
||||||
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
|
|
||||||
onCardClick={() => handleOpenModal(waitlist)}
|
|
||||||
onJoinClick={() => handleOpenModal(waitlist)}
|
|
||||||
/>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
|
|
||||||
{/* Desktop Grid View */}
|
|
||||||
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{waitlists.map((waitlist) => (
|
|
||||||
<WaitlistCard
|
|
||||||
key={waitlist.waitlistId}
|
|
||||||
name={waitlist.name}
|
|
||||||
subHeading={waitlist.subHeading}
|
|
||||||
description={waitlist.description}
|
|
||||||
imageUrl={waitlist.imageUrls[0] || null}
|
|
||||||
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
|
|
||||||
onCardClick={() => handleOpenModal(waitlist)}
|
|
||||||
onJoinClick={() => handleOpenModal(waitlist)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Single Modal for both viewing and joining */}
|
|
||||||
{selectedWaitlist && (
|
|
||||||
<WaitlistDetailModal
|
|
||||||
waitlist={selectedWaitlist}
|
|
||||||
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlistId)}
|
|
||||||
onClose={() => setSelectedWaitlist(null)}
|
|
||||||
onJoinSuccess={handleJoinSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
|
|
||||||
import {
|
|
||||||
useGetV2GetTheAgentWaitlist,
|
|
||||||
useGetV2GetWaitlistIdsTheCurrentUserHasJoined,
|
|
||||||
getGetV2GetWaitlistIdsTheCurrentUserHasJoinedQueryKey,
|
|
||||||
} from "@/app/api/__generated__/endpoints/store/store";
|
|
||||||
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export function useWaitlistSection() {
|
|
||||||
const { user } = useSupabaseStore();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Fetch waitlists
|
|
||||||
const {
|
|
||||||
data: waitlistsResponse,
|
|
||||||
isLoading: waitlistsLoading,
|
|
||||||
isError: waitlistsError,
|
|
||||||
} = useGetV2GetTheAgentWaitlist();
|
|
||||||
|
|
||||||
// Fetch memberships if logged in
|
|
||||||
const { data: membershipsResponse, isLoading: membershipsLoading } =
|
|
||||||
useGetV2GetWaitlistIdsTheCurrentUserHasJoined({
|
|
||||||
query: {
|
|
||||||
enabled: !!user,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const waitlists: StoreWaitlistEntry[] = useMemo(() => {
|
|
||||||
if (waitlistsResponse?.status === 200) {
|
|
||||||
return waitlistsResponse.data.listings;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [waitlistsResponse]);
|
|
||||||
|
|
||||||
const joinedWaitlistIds: Set<string> = useMemo(() => {
|
|
||||||
if (membershipsResponse?.status === 200) {
|
|
||||||
return new Set(membershipsResponse.data);
|
|
||||||
}
|
|
||||||
return new Set();
|
|
||||||
}, [membershipsResponse]);
|
|
||||||
|
|
||||||
const isLoading = waitlistsLoading || (!!user && membershipsLoading);
|
|
||||||
const hasError = waitlistsError;
|
|
||||||
|
|
||||||
// Function to add a waitlist ID to joined set (called after successful join)
|
|
||||||
function markAsJoined(_waitlistId: string) {
|
|
||||||
// Invalidate the memberships query to refetch
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2GetWaitlistIdsTheCurrentUserHasJoinedQueryKey(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined };
|
|
||||||
}
|
|
||||||
@@ -5234,301 +5234,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/store/admin/waitlist": {
|
|
||||||
"get": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "List All Waitlists",
|
|
||||||
"description": "Get all waitlists with admin details (admin only).\n\nReturns:\n WaitlistAdminListResponse with all waitlists",
|
|
||||||
"operationId": "getV2List all waitlists",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/WaitlistAdminListResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }]
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "Create Waitlist",
|
|
||||||
"description": "Create a new waitlist (admin only).\n\nArgs:\n request: Waitlist creation details\n user_id: Authenticated admin user creating the waitlist\n\nReturns:\n WaitlistAdminResponse with the created waitlist details",
|
|
||||||
"operationId": "postV2Create waitlist",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/WaitlistCreateRequest" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/WaitlistAdminResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/store/admin/waitlist/{waitlist_id}": {
|
|
||||||
"delete": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "Delete Waitlist",
|
|
||||||
"description": "Soft delete a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to delete\n\nReturns:\n Success message",
|
|
||||||
"operationId": "deleteV2Delete waitlist",
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "waitlist_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the waitlist",
|
|
||||||
"title": "Waitlist Id"
|
|
||||||
},
|
|
||||||
"description": "The ID of the waitlist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": { "application/json": { "schema": {} } }
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"get": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "Get Waitlist Details",
|
|
||||||
"description": "Get a single waitlist with admin details (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to retrieve\n\nReturns:\n WaitlistAdminResponse with waitlist details",
|
|
||||||
"operationId": "getV2Get waitlist details",
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "waitlist_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the waitlist",
|
|
||||||
"title": "Waitlist Id"
|
|
||||||
},
|
|
||||||
"description": "The ID of the waitlist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/WaitlistAdminResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "Update Waitlist",
|
|
||||||
"description": "Update a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to update\n request: Fields to update\n\nReturns:\n WaitlistAdminResponse with updated waitlist details",
|
|
||||||
"operationId": "putV2Update waitlist",
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "waitlist_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the waitlist",
|
|
||||||
"title": "Waitlist Id"
|
|
||||||
},
|
|
||||||
"description": "The ID of the waitlist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/WaitlistUpdateRequest" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/WaitlistAdminResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/store/admin/waitlist/{waitlist_id}/link": {
|
|
||||||
"post": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "Link Waitlist to Store Listing",
|
|
||||||
"description": "Link a waitlist to a store listing (admin only).\n\nWhen the linked store listing is approved/published, waitlist users\nwill be automatically notified.\n\nArgs:\n waitlist_id: ID of the waitlist\n store_listing_id: ID of the store listing to link\n\nReturns:\n WaitlistAdminResponse with updated waitlist details",
|
|
||||||
"operationId": "postV2Link waitlist to store listing",
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "waitlist_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the waitlist",
|
|
||||||
"title": "Waitlist Id"
|
|
||||||
},
|
|
||||||
"description": "The ID of the waitlist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Body_postV2Link_waitlist_to_store_listing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/WaitlistAdminResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/store/admin/waitlist/{waitlist_id}/signups": {
|
|
||||||
"get": {
|
|
||||||
"tags": ["v2", "admin", "store", "admin", "waitlist"],
|
|
||||||
"summary": "Get Waitlist Signups",
|
|
||||||
"description": "Get all signups for a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist\n\nReturns:\n WaitlistSignupListResponse with all signups",
|
|
||||||
"operationId": "getV2Get waitlist signups",
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "waitlist_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the waitlist",
|
|
||||||
"title": "Waitlist Id"
|
|
||||||
},
|
|
||||||
"description": "The ID of the waitlist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/WaitlistSignupListResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/store/agents": {
|
"/api/store/agents": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["v2", "store", "public"],
|
"tags": ["v2", "store", "public"],
|
||||||
@@ -6374,89 +6079,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/store/waitlist": {
|
|
||||||
"get": {
|
|
||||||
"tags": ["v2", "store", "public"],
|
|
||||||
"summary": "Get the agent waitlist",
|
|
||||||
"description": "Get all active waitlists for public display.",
|
|
||||||
"operationId": "getV2Get the agent waitlist",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/StoreWaitlistsAllResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/store/waitlist/my-memberships": {
|
|
||||||
"get": {
|
|
||||||
"tags": ["v2", "store", "private"],
|
|
||||||
"summary": "Get waitlist IDs the current user has joined",
|
|
||||||
"description": "Returns list of waitlist IDs the authenticated user has joined.",
|
|
||||||
"operationId": "getV2Get waitlist ids the current user has joined",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Response Getv2Get Waitlist Ids The Current User Has Joined"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [{ "HTTPBearerJWT": [] }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/store/waitlist/{waitlist_id}/join": {
|
|
||||||
"post": {
|
|
||||||
"tags": ["v2", "store", "public"],
|
|
||||||
"summary": "Add self to the agent waitlist",
|
|
||||||
"description": "Add the current user to the agent waitlist.",
|
|
||||||
"operationId": "postV2Add self to the agent waitlist",
|
|
||||||
"security": [{ "HTTPBearer": [] }],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "waitlist_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the waitlist to join",
|
|
||||||
"title": "Waitlist Id"
|
|
||||||
},
|
|
||||||
"description": "The ID of the waitlist to join"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Body_postV2Add_self_to_the_agent_waitlist"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/StoreWaitlistEntry" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"/api/workspace/files/{file_id}/download": {
|
"/api/workspace/files/{file_id}/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["workspace"],
|
"tags": ["workspace"],
|
||||||
@@ -7256,17 +6878,6 @@
|
|||||||
"required": ["store_listing_version_id"],
|
"required": ["store_listing_version_id"],
|
||||||
"title": "Body_postV2Add marketplace agent"
|
"title": "Body_postV2Add marketplace agent"
|
||||||
},
|
},
|
||||||
"Body_postV2Add_self_to_the_agent_waitlist": {
|
|
||||||
"properties": {
|
|
||||||
"email": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Email",
|
|
||||||
"description": "Email address for unauthenticated users"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"title": "Body_postV2Add self to the agent waitlist"
|
|
||||||
},
|
|
||||||
"Body_postV2Execute_a_preset": {
|
"Body_postV2Execute_a_preset": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
@@ -7285,18 +6896,6 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Body_postV2Execute a preset"
|
"title": "Body_postV2Execute a preset"
|
||||||
},
|
},
|
||||||
"Body_postV2Link_waitlist_to_store_listing": {
|
|
||||||
"properties": {
|
|
||||||
"store_listing_id": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Store Listing Id",
|
|
||||||
"description": "The ID of the store listing"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": ["store_listing_id"],
|
|
||||||
"title": "Body_postV2Link waitlist to store listing"
|
|
||||||
},
|
|
||||||
"Body_postV2Upload_submission_media": {
|
"Body_postV2Upload_submission_media": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"file": { "type": "string", "format": "binary", "title": "File" }
|
"file": { "type": "string", "format": "binary", "title": "File" }
|
||||||
@@ -9225,8 +8824,7 @@
|
|||||||
"REFUND_REQUEST",
|
"REFUND_REQUEST",
|
||||||
"REFUND_PROCESSED",
|
"REFUND_PROCESSED",
|
||||||
"AGENT_APPROVED",
|
"AGENT_APPROVED",
|
||||||
"AGENT_REJECTED",
|
"AGENT_REJECTED"
|
||||||
"WAITLIST_LAUNCH"
|
|
||||||
],
|
],
|
||||||
"title": "NotificationType"
|
"title": "NotificationType"
|
||||||
},
|
},
|
||||||
@@ -10861,57 +10459,6 @@
|
|||||||
"required": ["submissions", "pagination"],
|
"required": ["submissions", "pagination"],
|
||||||
"title": "StoreSubmissionsResponse"
|
"title": "StoreSubmissionsResponse"
|
||||||
},
|
},
|
||||||
"StoreWaitlistEntry": {
|
|
||||||
"properties": {
|
|
||||||
"waitlistId": { "type": "string", "title": "Waitlistid" },
|
|
||||||
"slug": { "type": "string", "title": "Slug" },
|
|
||||||
"name": { "type": "string", "title": "Name" },
|
|
||||||
"subHeading": { "type": "string", "title": "Subheading" },
|
|
||||||
"videoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Videourl"
|
|
||||||
},
|
|
||||||
"agentOutputDemoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Agentoutputdemourl"
|
|
||||||
},
|
|
||||||
"imageUrls": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Imageurls"
|
|
||||||
},
|
|
||||||
"description": { "type": "string", "title": "Description" },
|
|
||||||
"categories": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Categories"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"waitlistId",
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"subHeading",
|
|
||||||
"imageUrls",
|
|
||||||
"description",
|
|
||||||
"categories"
|
|
||||||
],
|
|
||||||
"title": "StoreWaitlistEntry",
|
|
||||||
"description": "Public waitlist entry - no PII fields exposed."
|
|
||||||
},
|
|
||||||
"StoreWaitlistsAllResponse": {
|
|
||||||
"properties": {
|
|
||||||
"listings": {
|
|
||||||
"items": { "$ref": "#/components/schemas/StoreWaitlistEntry" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Listings"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": ["listings"],
|
|
||||||
"title": "StoreWaitlistsAllResponse"
|
|
||||||
},
|
|
||||||
"StreamChatRequest": {
|
"StreamChatRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"message": { "type": "string", "title": "Message" },
|
"message": { "type": "string", "title": "Message" },
|
||||||
@@ -12731,203 +12278,6 @@
|
|||||||
"required": ["loc", "msg", "type"],
|
"required": ["loc", "msg", "type"],
|
||||||
"title": "ValidationError"
|
"title": "ValidationError"
|
||||||
},
|
},
|
||||||
"WaitlistAdminListResponse": {
|
|
||||||
"properties": {
|
|
||||||
"waitlists": {
|
|
||||||
"items": { "$ref": "#/components/schemas/WaitlistAdminResponse" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Waitlists"
|
|
||||||
},
|
|
||||||
"totalCount": { "type": "integer", "title": "Totalcount" }
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": ["waitlists", "totalCount"],
|
|
||||||
"title": "WaitlistAdminListResponse",
|
|
||||||
"description": "Response model for listing all waitlists (admin view)."
|
|
||||||
},
|
|
||||||
"WaitlistAdminResponse": {
|
|
||||||
"properties": {
|
|
||||||
"id": { "type": "string", "title": "Id" },
|
|
||||||
"createdAt": { "type": "string", "title": "Createdat" },
|
|
||||||
"updatedAt": { "type": "string", "title": "Updatedat" },
|
|
||||||
"slug": { "type": "string", "title": "Slug" },
|
|
||||||
"name": { "type": "string", "title": "Name" },
|
|
||||||
"subHeading": { "type": "string", "title": "Subheading" },
|
|
||||||
"description": { "type": "string", "title": "Description" },
|
|
||||||
"categories": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Categories"
|
|
||||||
},
|
|
||||||
"imageUrls": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Imageurls"
|
|
||||||
},
|
|
||||||
"videoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Videourl"
|
|
||||||
},
|
|
||||||
"agentOutputDemoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Agentoutputdemourl"
|
|
||||||
},
|
|
||||||
"status": { "$ref": "#/components/schemas/WaitlistExternalStatus" },
|
|
||||||
"votes": { "type": "integer", "title": "Votes" },
|
|
||||||
"signupCount": { "type": "integer", "title": "Signupcount" },
|
|
||||||
"storeListingId": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Storelistingid"
|
|
||||||
},
|
|
||||||
"owningUserId": { "type": "string", "title": "Owninguserid" }
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"createdAt",
|
|
||||||
"updatedAt",
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"subHeading",
|
|
||||||
"description",
|
|
||||||
"categories",
|
|
||||||
"imageUrls",
|
|
||||||
"status",
|
|
||||||
"votes",
|
|
||||||
"signupCount",
|
|
||||||
"owningUserId"
|
|
||||||
],
|
|
||||||
"title": "WaitlistAdminResponse",
|
|
||||||
"description": "Admin response model with full waitlist details including internal data."
|
|
||||||
},
|
|
||||||
"WaitlistCreateRequest": {
|
|
||||||
"properties": {
|
|
||||||
"name": { "type": "string", "title": "Name" },
|
|
||||||
"slug": { "type": "string", "title": "Slug" },
|
|
||||||
"subHeading": { "type": "string", "title": "Subheading" },
|
|
||||||
"description": { "type": "string", "title": "Description" },
|
|
||||||
"categories": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Categories",
|
|
||||||
"default": []
|
|
||||||
},
|
|
||||||
"imageUrls": {
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Imageurls",
|
|
||||||
"default": []
|
|
||||||
},
|
|
||||||
"videoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Videourl"
|
|
||||||
},
|
|
||||||
"agentOutputDemoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Agentoutputdemourl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": ["name", "slug", "subHeading", "description"],
|
|
||||||
"title": "WaitlistCreateRequest",
|
|
||||||
"description": "Request model for creating a new waitlist."
|
|
||||||
},
|
|
||||||
"WaitlistExternalStatus": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["DONE", "NOT_STARTED", "CANCELED", "WORK_IN_PROGRESS"],
|
|
||||||
"title": "WaitlistExternalStatus"
|
|
||||||
},
|
|
||||||
"WaitlistSignup": {
|
|
||||||
"properties": {
|
|
||||||
"type": { "type": "string", "title": "Type" },
|
|
||||||
"userId": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Userid"
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Email"
|
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Username"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": ["type"],
|
|
||||||
"title": "WaitlistSignup",
|
|
||||||
"description": "Individual signup entry for a waitlist."
|
|
||||||
},
|
|
||||||
"WaitlistSignupListResponse": {
|
|
||||||
"properties": {
|
|
||||||
"waitlistId": { "type": "string", "title": "Waitlistid" },
|
|
||||||
"signups": {
|
|
||||||
"items": { "$ref": "#/components/schemas/WaitlistSignup" },
|
|
||||||
"type": "array",
|
|
||||||
"title": "Signups"
|
|
||||||
},
|
|
||||||
"totalCount": { "type": "integer", "title": "Totalcount" }
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": ["waitlistId", "signups", "totalCount"],
|
|
||||||
"title": "WaitlistSignupListResponse",
|
|
||||||
"description": "Response model for listing waitlist signups."
|
|
||||||
},
|
|
||||||
"WaitlistUpdateRequest": {
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Name"
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Slug"
|
|
||||||
},
|
|
||||||
"subHeading": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Subheading"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Description"
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"anyOf": [
|
|
||||||
{ "items": { "type": "string" }, "type": "array" },
|
|
||||||
{ "type": "null" }
|
|
||||||
],
|
|
||||||
"title": "Categories"
|
|
||||||
},
|
|
||||||
"imageUrls": {
|
|
||||||
"anyOf": [
|
|
||||||
{ "items": { "type": "string" }, "type": "array" },
|
|
||||||
{ "type": "null" }
|
|
||||||
],
|
|
||||||
"title": "Imageurls"
|
|
||||||
},
|
|
||||||
"videoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Videourl"
|
|
||||||
},
|
|
||||||
"agentOutputDemoUrl": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Agentoutputdemourl"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"anyOf": [
|
|
||||||
{ "$ref": "#/components/schemas/WaitlistExternalStatus" },
|
|
||||||
{ "type": "null" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"storeListingId": {
|
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
|
||||||
"title": "Storelistingid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"title": "WaitlistUpdateRequest",
|
|
||||||
"description": "Request model for updating a waitlist."
|
|
||||||
},
|
|
||||||
"Webhook": {
|
"Webhook": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "title": "Id" },
|
"id": { "type": "string", "title": "Id" },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessi
|
|||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -55,6 +56,10 @@ export function ChatContainer({
|
|||||||
onStreamingChange?.(isStreaming);
|
onStreamingChange?.(isStreaming);
|
||||||
}, [isStreaming, onStreamingChange]);
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
const isMobile =
|
||||||
|
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -122,7 +127,11 @@ export function ChatContainer({
|
|||||||
disabled={isStreaming || !sessionId}
|
disabled={isStreaming || !sessionId}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStop={stopStreaming}
|
onStop={stopStreaming}
|
||||||
placeholder="What else can I help with?"
|
placeholder={
|
||||||
|
isMobile
|
||||||
|
? "You can search or just ask"
|
||||||
|
: 'You can search or just ask — e.g. "create a blog post outline"'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,20 +74,19 @@ export function ChatInput({
|
|||||||
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!value && !isRecording && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 top-0.5 flex items-center justify-start pl-14 text-[1rem] text-zinc-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{isTranscribing ? "Transcribing..." : placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<textarea
|
<textarea
|
||||||
id={inputId}
|
id={inputId}
|
||||||
aria-label="Chat message input"
|
aria-label="Chat message input"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
isTranscribing
|
||||||
|
? "Transcribing..."
|
||||||
|
: isRecording
|
||||||
|
? ""
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
disabled={isInputDisabled}
|
disabled={isInputDisabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -123,14 +122,13 @@ export function ChatInput({
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
||||||
onClick={toggleRecording}
|
onClick={toggleRecording}
|
||||||
disabled={disabled || isTranscribing || isStreaming}
|
disabled={disabled || isTranscribing}
|
||||||
className={cn(
|
className={cn(
|
||||||
isRecording
|
isRecording
|
||||||
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
||||||
: isTranscribing
|
: isTranscribing
|
||||||
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
||||||
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
||||||
isStreaming && "opacity-40",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isTranscribing ? (
|
{isTranscribing ? (
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function AudioWaveform({
|
|||||||
// Create audio context and analyser
|
// Create audio context and analyser
|
||||||
const audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 512;
|
||||||
analyser.smoothingTimeConstant = 0.3;
|
analyser.smoothingTimeConstant = 0.8;
|
||||||
|
|
||||||
// Connect the stream to the analyser
|
// Connect the stream to the analyser
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
@@ -73,11 +73,10 @@ export function AudioWaveform({
|
|||||||
maxAmplitude = Math.max(maxAmplitude, amplitude);
|
maxAmplitude = Math.max(maxAmplitude, amplitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize amplitude (0-128 range) to 0-1
|
// Map amplitude (0-128) to bar height
|
||||||
const normalized = maxAmplitude / 128;
|
const normalized = (maxAmplitude / 128) * 255;
|
||||||
// Apply sensitivity boost (multiply by 4) and use sqrt curve to amplify quiet sounds
|
const height =
|
||||||
const boosted = Math.min(1, Math.sqrt(normalized) * 4);
|
minBarHeight + (normalized / 255) * (maxBarHeight - minBarHeight);
|
||||||
const height = minBarHeight + boosted * (maxBarHeight - minBarHeight);
|
|
||||||
newBars.push(height);
|
newBars.push(height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function useVoiceRecording({
|
|||||||
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showMicButton = isSupported;
|
const showMicButton = isSupported && !isStreaming;
|
||||||
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
|
|||||||
@@ -41,17 +41,7 @@ export function HostScopedCredentialsModal({
|
|||||||
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
host: z
|
host: z.string().min(1, "Host is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Host is required")
|
|
||||||
.refine((val) => !/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(val), {
|
|
||||||
message: "Enter only the host (e.g. api.example.com), not a full URL",
|
|
||||||
})
|
|
||||||
.refine((val) => !val.includes("/"), {
|
|
||||||
message:
|
|
||||||
"Enter only the host (e.g. api.example.com), without a trailing path. " +
|
|
||||||
"You may specify a port (e.g. api.example.com:8080) if needed.",
|
|
||||||
}),
|
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store |
|
| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store |
|
||||||
| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API |
|
| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API |
|
||||||
| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data |
|
| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data |
|
||||||
|
| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear |
|
||||||
| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty |
|
| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty |
|
||||||
| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library |
|
| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library |
|
||||||
| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes |
|
| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes |
|
||||||
@@ -570,7 +571,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue |
|
| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue |
|
||||||
| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear |
|
| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear |
|
||||||
| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee |
|
| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee |
|
||||||
| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear |
|
|
||||||
| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear |
|
| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear |
|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ Searches for issues on Linear
|
|||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
<!-- MANUAL: how_it_works -->
|
<!-- MANUAL: how_it_works -->
|
||||||
This block searches for issues in Linear using a text query. It searches across issue titles, descriptions, and other fields to find matching issues. You can limit the number of results returned using the `max_results` parameter (default: 10, max: 100) to control token consumption and response size.
|
This block searches for issues in Linear using a text query. It searches across issue titles, descriptions, and other fields to find matching issues.
|
||||||
|
|
||||||
Optionally filter results by team name to narrow searches to specific workspaces. If a team name is provided, the block resolves it to a team ID before searching. Returns matching issues with their state, creation date, project, and assignee information. If the search or team resolution fails, an error message is returned.
|
Returns a list of issues matching the search term.
|
||||||
<!-- END MANUAL -->
|
<!-- END MANUAL -->
|
||||||
|
|
||||||
### Inputs
|
### Inputs
|
||||||
@@ -100,14 +100,12 @@ Optionally filter results by team name to narrow searches to specific workspaces
|
|||||||
| Input | Description | Type | Required |
|
| Input | Description | Type | Required |
|
||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| term | Term to search for issues | str | Yes |
|
| term | Term to search for issues | str | Yes |
|
||||||
| max_results | Maximum number of results to return | int | No |
|
|
||||||
| team_name | Optional team name to filter results (e.g., 'Internal', 'Open Source') | str | No |
|
|
||||||
|
|
||||||
### Outputs
|
### Outputs
|
||||||
|
|
||||||
| Output | Description | Type |
|
| Output | Description | Type |
|
||||||
|--------|-------------|------|
|
|--------|-------------|------|
|
||||||
| error | Error message if the search failed | str |
|
| error | Error message if the operation failed | str |
|
||||||
| issues | List of issues | List[Issue] |
|
| issues | List of issues | List[Issue] |
|
||||||
|
|
||||||
### Possible use case
|
### Possible use case
|
||||||
|
|||||||
Reference in New Issue
Block a user