platform(fix): Fix missing Profiles (#9424)

### What's This PR About?

This PR makes a few simple improvements to how user profiles are handled
in the app:

- **Always Have a Profile:**  
If a user doesn't already have a profile, the system now automatically
creates one with some default info (including a fun, randomly generated
username). This way, you never end up with a missing profile.

- **Better Profile Updates:**  
  Removes the creation of profiles on failed get requests
This commit is contained in:
Swifty
2025-02-06 11:20:32 +01:00
committed by GitHub
parent 6183ed5a63
commit 8181ee8cd1
8 changed files with 272 additions and 151 deletions

View File

@@ -1,5 +1,4 @@
import logging
import random
from datetime import datetime
from typing import Optional
@@ -17,6 +16,25 @@ from backend.data.graph import GraphModel
logger = logging.getLogger(__name__)
def sanitize_query(query: str | None) -> str | None:
if query is None:
return query
query = query.strip()[:100]
return (
query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("'", "\\'")
.replace('"', '\\"')
.replace(";", "\\;")
.replace("--", "\\--")
.replace("/*", "\\/*")
.replace("*/", "\\*/")
)
async def get_store_agents(
featured: bool = False,
creator: str | None = None,
@@ -29,29 +47,7 @@ async def get_store_agents(
logger.debug(
f"Getting store agents. featured={featured}, creator={creator}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
)
sanitized_query = None
# Sanitize and validate search query by escaping special characters
if search_query is not None:
sanitized_query = search_query.strip()
if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit
raise backend.server.v2.store.exceptions.DatabaseError(
f"Invalid search query: len({len(sanitized_query)}) query: {search_query}"
)
# Escape special SQL characters
sanitized_query = (
sanitized_query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("'", "\\'")
.replace('"', '\\"')
.replace(";", "\\;")
.replace("--", "\\--")
.replace("/*", "\\/*")
.replace("*/", "\\*/")
)
sanitized_query = sanitize_query(search_query)
where_clause = {}
if featured:
@@ -93,8 +89,8 @@ async def get_store_agents(
slug=agent.slug,
agent_name=agent.agent_name,
agent_image=agent.agent_image[0] if agent.agent_image else "",
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
creator=agent.creator_username or "Needs Profile",
creator_avatar=agent.creator_avatar or "",
sub_heading=agent.sub_heading,
description=agent.description,
runs=agent.runs,
@@ -114,7 +110,7 @@ async def get_store_agents(
),
)
except Exception as e:
logger.error(f"Error getting store agents: {str(e)}")
logger.error(f"Error getting store agents: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch store agents"
) from e
@@ -156,7 +152,7 @@ async def get_store_agent_details(
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store agent details: {str(e)}")
logger.error(f"Error getting store agent details: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent details"
) from e
@@ -270,7 +266,7 @@ async def get_store_creators(
),
)
except Exception as e:
logger.error(f"Error getting store creators: {str(e)}")
logger.error(f"Error getting store creators: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch store creators"
) from e
@@ -307,7 +303,7 @@ async def get_store_creator_details(
except backend.server.v2.store.exceptions.CreatorNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store creator details: {str(e)}")
logger.error(f"Error getting store creator details: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch creator details"
) from e
@@ -366,7 +362,7 @@ async def get_store_submissions(
)
except Exception as e:
logger.error(f"Error fetching store submissions: {str(e)}")
logger.error(f"Error fetching store submissions: {e}")
# Return empty response rather than exposing internal errors
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
@@ -416,7 +412,7 @@ async def delete_store_submission(
return True
except Exception as e:
logger.error(f"Error deleting store submission: {str(e)}")
logger.error(f"Error deleting store submission: {e}")
return False
@@ -539,7 +535,7 @@ async def create_store_submission(
):
raise
except prisma.errors.PrismaError as e:
logger.error(f"Database error creating store submission: {str(e)}")
logger.error(f"Database error creating store submission: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store submission"
) from e
@@ -579,7 +575,7 @@ async def create_store_review(
)
except prisma.errors.PrismaError as e:
logger.error(f"Database error creating store review: {str(e)}")
logger.error(f"Database error creating store review: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store review"
) from e
@@ -587,7 +583,7 @@ async def create_store_review(
async def get_user_profile(
user_id: str,
) -> backend.server.v2.store.model.ProfileDetails:
) -> backend.server.v2.store.model.ProfileDetails | None:
logger.debug(f"Getting user profile for {user_id}")
try:
@@ -596,25 +592,7 @@ async def get_user_profile(
)
if not profile:
logger.warning(f"Profile not found for user {user_id}")
new_profile = await prisma.models.Profile.prisma().create(
data=prisma.types.ProfileCreateInput(
userId=user_id,
name="No Profile Data",
username=f"{random.choice(['happy', 'clever', 'swift', 'bright', 'wise'])}-{random.choice(['fox', 'wolf', 'bear', 'eagle', 'owl'])}_{random.randint(1000,9999)}".lower(),
description="No Profile Data",
links=[],
avatarUrl="",
)
)
return backend.server.v2.store.model.ProfileDetails(
name=new_profile.name,
username=new_profile.username,
description=new_profile.description,
links=new_profile.links,
avatar_url=new_profile.avatarUrl,
)
return None
return backend.server.v2.store.model.ProfileDetails(
name=profile.name,
username=profile.username,
@@ -623,115 +601,90 @@ async def get_user_profile(
avatar_url=profile.avatarUrl,
)
except Exception as e:
logger.error(f"Error getting user profile: {str(e)}")
return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatar_url="",
)
logger.error("Error getting user profile: %s", e)
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to get user profile"
) from e
async def update_or_create_profile(
async def update_profile(
user_id: str, profile: backend.server.v2.store.model.Profile
) -> backend.server.v2.store.model.CreatorDetails:
"""
Update the store profile for a user. Creates a new profile if one doesn't exist.
Only allows updating if the user_id matches the owning user.
If a field is None, it will not overwrite the existing value in the case of an update.
Update the store profile for a user or create a new one if it doesn't exist.
Args:
user_id: ID of the authenticated user
profile: Updated profile details
Returns:
CreatorDetails: The updated profile
CreatorDetails: The updated or created profile details
Raises:
HTTPException: If user is not authorized to update this profile
DatabaseError: If profile cannot be updated due to database issues
DatabaseError: If there's an issue updating or creating the profile
"""
logger.info(f"Updating profile for user {user_id} data: {profile}")
logger.info("Updating profile for user %s with data: %s", user_id, profile)
try:
# Sanitize username to only allow letters and hyphens
# Sanitize username to allow only letters, numbers, and hyphens
username = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else ""
for c in profile.username
).lower()
# Check if profile exists for the given user_id
existing_profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id}
)
# If no profile exists, create a new one
if not existing_profile:
logger.debug(
f"No existing profile found. Creating new profile for user {user_id}"
)
# Create new profile since one doesn't exist
new_profile = await prisma.models.Profile.prisma().create(
data={
"userId": user_id,
"name": profile.name,
"username": username,
"description": profile.description,
"links": profile.links or [],
"avatarUrl": profile.avatar_url,
"isFeatured": False,
}
raise backend.server.v2.store.exceptions.ProfileNotFoundError(
f"Profile not found for user {user_id}. This should not be possible."
)
return backend.server.v2.store.model.CreatorDetails(
name=new_profile.name,
username=new_profile.username,
description=new_profile.description,
links=new_profile.links,
avatar_url=new_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
# Verify that the user is authorized to update this profile
if existing_profile.userId != user_id:
logger.error(
"Unauthorized update attempt for profile %s by user %s",
existing_profile.userId,
user_id,
)
raise backend.server.v2.store.exceptions.DatabaseError(
f"Unauthorized update attempt for profile {existing_profile.id} by user {user_id}"
)
else:
logger.debug(f"Updating existing profile for user {user_id}")
# Update only provided fields for the existing profile
update_data = {}
if profile.name is not None:
update_data["name"] = profile.name
if profile.username is not None:
update_data["username"] = username
if profile.description is not None:
update_data["description"] = profile.description
if profile.links is not None:
update_data["links"] = profile.links
if profile.avatar_url is not None:
update_data["avatarUrl"] = profile.avatar_url
# Update the existing profile
updated_profile = await prisma.models.Profile.prisma().update(
where={"id": existing_profile.id},
data=prisma.types.ProfileUpdateInput(**update_data),
)
if updated_profile is None:
logger.error(f"Failed to update profile for user {user_id}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
)
logger.debug("Updating existing profile for user %s", user_id)
# Prepare update data, only including non-None values
update_data = {}
if profile.name is not None:
update_data["name"] = profile.name
if profile.username is not None:
update_data["username"] = username
if profile.description is not None:
update_data["description"] = profile.description
if profile.links is not None:
update_data["links"] = profile.links
if profile.avatar_url is not None:
update_data["avatarUrl"] = profile.avatar_url
return backend.server.v2.store.model.CreatorDetails(
name=updated_profile.name,
username=updated_profile.username,
description=updated_profile.description,
links=updated_profile.links,
avatar_url=updated_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
# Update the existing profile
updated_profile = await prisma.models.Profile.prisma().update(
where={"id": existing_profile.id},
data=prisma.types.ProfileUpdateInput(**update_data),
)
if updated_profile is None:
logger.error("Failed to update profile for user %s", user_id)
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
)
return backend.server.v2.store.model.CreatorDetails(
name=updated_profile.name,
username=updated_profile.username,
description=updated_profile.description,
links=updated_profile.links,
avatar_url=updated_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
)
except prisma.errors.PrismaError as e:
logger.error(f"Database error updating profile: {str(e)}")
logger.error("Database error updating profile: %s", e)
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
) from e
@@ -796,7 +749,7 @@ async def get_my_agents(
),
)
except Exception as e:
logger.error(f"Error getting my agents: {str(e)}")
logger.error(f"Error getting my agents: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch my agents"
) from e
@@ -840,7 +793,7 @@ async def get_agent(
return graph
except Exception as e:
logger.error(f"Error getting agent: {str(e)}")
logger.error(f"Error getting agent: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent"
) from e
@@ -905,7 +858,7 @@ async def review_store_submission(
return submission
except Exception as e:
logger.error(f"Could not create store submission review: {str(e)}")
logger.error(f"Could not create store submission review: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store submission review"
) from e

View File

@@ -158,6 +158,26 @@ async def test_create_store_submission(mocker):
agentId="agent-id",
agentVersion=1,
owningUserId="user-id",
StoreListingVersions=[
prisma.models.StoreListingVersion(
id="version-id",
agentId="agent-id",
agentVersion=1,
slug="test-agent",
name="Test Agent",
description="Test description",
createdAt=datetime.now(),
updatedAt=datetime.now(),
subHeading="Test heading",
imageUrls=["image.jpg"],
categories=["test"],
isFeatured=False,
isDeleted=False,
version=1,
isAvailable=True,
isApproved=False,
)
],
)
# Mock prisma calls
@@ -181,6 +201,7 @@ async def test_create_store_submission(mocker):
# Verify results
assert result.name == "Test Agent"
assert result.description == "Test description"
assert result.store_listing_version_id == "version-id"
# Verify mocks called correctly
mock_agent_graph.return_value.find_first.assert_called_once()
@@ -195,6 +216,7 @@ async def test_update_profile(mocker):
id="profile-id",
name="Test Creator",
username="creator",
userId="user-id",
description="Test description",
links=["link1"],
avatarUrl="avatar.jpg",
@@ -221,7 +243,7 @@ async def test_update_profile(mocker):
)
# Call function
result = await db.update_or_create_profile("user-id", profile)
result = await db.update_profile("user-id", profile)
# Verify results
assert result.username == "creator"
@@ -237,7 +259,7 @@ async def test_get_user_profile(mocker):
# Mock data
mock_profile = prisma.models.Profile(
id="profile-id",
name="No Profile Data",
name="Test User",
username="testuser",
description="Test description",
links=["link1", "link2"],
@@ -245,20 +267,22 @@ async def test_get_user_profile(mocker):
isFeatured=False,
createdAt=datetime.now(),
updatedAt=datetime.now(),
userId="user-id",
)
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_unique = mocker.AsyncMock(
mock_profile_db.return_value.find_first = mocker.AsyncMock(
return_value=mock_profile
)
# Call function
result = await db.get_user_profile("user-id")
assert result is not None
# Verify results
assert result.name == "No Profile Data"
assert result.username == "No Profile Data"
assert result.description == "No Profile Data"
assert result.links == []
assert result.avatar_url == ""
assert result.name == "Test User"
assert result.username == "testuser"
assert result.description == "Test description"
assert result.links == ["link1", "link2"]
assert result.avatar_url == "avatar.jpg"

View File

@@ -42,6 +42,11 @@ async def get_profile(
"""
try:
profile = await backend.server.v2.store.db.get_user_profile(user_id)
if profile is None:
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Profile not found"},
)
return profile
except Exception:
logger.exception("Exception occurred whilst getting user profile")
@@ -77,7 +82,7 @@ async def update_or_create_profile(
HTTPException: If there is an error updating the profile
"""
try:
updated_profile = await backend.server.v2.store.db.update_or_create_profile(
updated_profile = await backend.server.v2.store.db.update_profile(
user_id=user_id, profile=profile
)
return updated_profile

View File

@@ -0,0 +1,109 @@
CREATE OR REPLACE FUNCTION generate_username()
RETURNS TEXT AS $$
DECLARE
-- Random username generation
selected_adjective TEXT;
selected_animal TEXT;
random_int INT;
generated_username TEXT;
BEGIN
FOR i IN 1..10 LOOP
SELECT unnest
INTO selected_adjective
FROM (VALUES ('happy'), ('clever'), ('swift'), ('bright'), ('wise'), ('funny'), ('cool'), ('awesome'), ('amazing'), ('fantastic'), ('wonderful')) AS t(unnest)
ORDER BY random()
LIMIT 1;
SELECT unnest
INTO selected_animal
FROM (VALUES ('fox'), ('wolf'), ('bear'), ('eagle'), ('owl'), ('tiger'), ('lion'), ('elephant'), ('giraffe'), ('zebra')) AS t(unnest)
ORDER BY random()
LIMIT 1;
SELECT floor(random() * (99999 - 10000 + 1) + 10000)::int
INTO random_int;
generated_username := lower(selected_adjective || '-' || selected_animal || '-' || random_int);
-- Check if username is already taken
IF NOT EXISTS (
SELECT 1 FROM platform."Profile" WHERE username = generated_username
) THEN
-- Username is unique, exit the loop
EXIT;
END IF;
-- If we've tried 10 times and still haven't found a unique username
IF i = 10 THEN
RAISE EXCEPTION 'Unable to generate unique username after 10 attempts';
END IF;
END LOOP;
RETURN generated_username;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION add_user_and_profile_to_platform()
RETURNS TRIGGER AS $$
BEGIN
-- Exit early if NEW.id is null to prevent constraint violations
IF NEW.id IS NULL THEN
RAISE EXCEPTION 'Cannot create user/profile: id is null';
END IF;
/*
1) Insert into platform."User"
(If you already have such a row or want different columns, adjust below.)
*/
INSERT INTO platform."User" (id, email, "updatedAt")
VALUES (NEW.id, NEW.email, now());
/*
2) Insert into platform."Profile"
Adjust columns/types depending on how your "Profile" schema is defined:
- "links" might be text[], jsonb, or something else in your table.
- "avatarUrl" and "description" can be defaulted as well.
*/
INSERT INTO platform."Profile"
("id", "userId", name, username, description, links, "avatarUrl", "updatedAt")
VALUES
(
NEW.id,
NEW.id,
COALESCE(split_part(NEW.email, '@', 1), 'user'), -- handle null email
platform.generate_username(),
'I''m new here',
'{}', -- empty array or empty JSON, depending on your column definition
'',
now()
);
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Log the error details
RAISE NOTICE 'Error in add_user_and_profile_to_platform: %', SQLERRM;
-- Re-raise the error
RAISE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DO $$
BEGIN
-- Check if the auth schema and users table exist
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'auth'
AND table_name = 'users'
) THEN
-- Drop the trigger if it exists
DROP TRIGGER IF EXISTS user_added_to_platform ON auth.users;
-- Create the trigger
CREATE TRIGGER user_added_to_platform
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION add_user_and_profile_to_platform();
END IF;
END $$;

View File

@@ -0,0 +1,29 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'platform'
AND table_name = 'User'
) AND EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'platform'
AND table_name = 'Profile'
) THEN
INSERT INTO platform."Profile"
("id", "userId", name, username, description, links, "avatarUrl", "updatedAt")
SELECT
u.id,
u.id,
COALESCE(split_part(u.email, '@', 1), 'user'),
platform.generate_username(),
'I''m new here',
'{}',
'',
now()
FROM platform."User" u
LEFT JOIN platform."Profile" p ON u.id = p."userId"
WHERE p.id IS NULL;
END IF;
END $$;

View File

@@ -184,7 +184,7 @@ export default function CreditsPage() {
<b>Note:</b> For your safety, we will top up your balance{" "}
<b>at most once</b> per agent execution to prevent unintended
excessive charges. Therefore, ensure that the automatic top-up
amount is sufficient for your agent's operation.
amount is sufficient for your agent&apos;s operation.
</p>
{autoTopUpConfig?.amount ? (

View File

@@ -36,9 +36,8 @@ export async function signup(values: z.infer<typeof signupFormSchema>) {
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/marketplace/profile");
redirect("/");
},
);
}

View File

@@ -28,7 +28,9 @@ test.describe("Profile", () => {
// Verify email matches test worker's email
const displayedHandle = await profilePage.getDisplayedName();
test.expect(displayedHandle).toBe("No Profile Data");
test.expect(displayedHandle).not.toBeNull();
test.expect(displayedHandle).not.toBe("");
test.expect(displayedHandle).toBeDefined();
});
test("profile navigation is accessible from navbar", async ({ page }) => {