feat: add usernames, pretty URLs, cloning, and sharing docs
## Usernames
- Add username field to User model (unique, URL-friendly)
- Add slug field to Collection model (unique per user)
- Update sign-up flow to require username with availability checking
- Create username check API endpoint
## Pretty URLs
- Add route group (profile) with pretty URL pages:
- /{username} - User profile
- /{username}/agents/{uid} - Agent detail
- /{username}/agents/{uid}/chat - Chat redirect
- /{username}/collections/{slug} - Collection detail
- Add client-side redirects from old /agents/[id] and /collections/[id] URLs
## Cloning
- Add clone API endpoints for agents and collections
- Create CloneButton component
- Add AGENT_CLONED and COLLECTION_CLONED activity types
## Documentation
- Add /docs/sharing page explaining all shareable URLs
- Document cloning functionality and visibility settings
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1ff0e49e41
commit
eaaa40f130
28 changed files with 3061 additions and 14 deletions
|
|
@ -332,6 +332,7 @@ model User {
|
|||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
image String?
|
||||
username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
@ -346,6 +347,7 @@ model User {
|
|||
agentLikes AgentLike[]
|
||||
activities UserActivity[]
|
||||
|
||||
@@index([username])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +417,7 @@ model Collection {
|
|||
|
||||
// Collection metadata
|
||||
name String @db.VarChar(100)
|
||||
slug String? @db.VarChar(50) // URL-friendly identifier (nullable for migration)
|
||||
description String? @db.VarChar(500)
|
||||
isPublic Boolean @default(false) @map("is_public")
|
||||
likeCount Int @default(0) @map("like_count")
|
||||
|
|
@ -428,9 +431,10 @@ model Collection {
|
|||
agents AgentCollection[]
|
||||
likes CollectionLike[]
|
||||
|
||||
// Unique constraint: user can't have duplicate collection names
|
||||
@@unique([userId, name])
|
||||
// Unique constraint: user can't have duplicate collection slugs
|
||||
@@unique([userId, slug])
|
||||
@@index([userId])
|
||||
@@index([slug])
|
||||
@@index([isPublic])
|
||||
@@index([likeCount])
|
||||
@@index([createdAt])
|
||||
|
|
@ -726,6 +730,7 @@ enum ActivityType {
|
|||
AGENT_CREATED
|
||||
AGENT_UPDATED
|
||||
AGENT_DELETED
|
||||
AGENT_CLONED
|
||||
AGENT_TOOL_ADDED
|
||||
AGENT_TOOL_REMOVED
|
||||
AGENT_COLLECTION_ADDED
|
||||
|
|
@ -733,6 +738,7 @@ enum ActivityType {
|
|||
COLLECTION_CREATED
|
||||
COLLECTION_UPDATED
|
||||
COLLECTION_DELETED
|
||||
COLLECTION_CLONED
|
||||
COLLECTION_TOOL_ADDED
|
||||
COLLECTION_TOOL_REMOVED
|
||||
TOOL_LIKED
|
||||
|
|
|
|||
143
packages/db/scripts/populate-usernames-slugs.ts
Normal file
143
packages/db/scripts/populate-usernames-slugs.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Migration script to populate usernames and collection slugs for existing data.
|
||||
*
|
||||
* Run with: pnpm --filter=@tpmjs/db tsx scripts/populate-usernames-slugs.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Convert a display name to a URL-friendly slug/username.
|
||||
* - Lowercase
|
||||
* - Replace spaces and special chars with hyphens
|
||||
* - Remove consecutive hyphens
|
||||
* - Trim hyphens from start/end
|
||||
*/
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special chars except spaces and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Remove consecutive hyphens
|
||||
.replace(/^-+|-+$/g, '') // Trim hyphens from start/end
|
||||
.slice(0, 30); // Max length for username/slug
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique username by appending a number suffix if needed.
|
||||
*/
|
||||
async function generateUniqueUsername(baseName: string): Promise<string> {
|
||||
let username = slugify(baseName);
|
||||
|
||||
// If empty after slugify, use a default
|
||||
if (!username) {
|
||||
username = 'user';
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
const existing = await prisma.user.findUnique({ where: { username } });
|
||||
if (!existing) {
|
||||
return username;
|
||||
}
|
||||
|
||||
// Append numbers until unique
|
||||
let counter = 1;
|
||||
while (true) {
|
||||
const candidate = `${username.slice(0, 26)}-${counter}`; // Leave room for suffix
|
||||
const exists = await prisma.user.findUnique({ where: { username: candidate } });
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
counter++;
|
||||
if (counter > 1000) {
|
||||
throw new Error(`Could not generate unique username for ${baseName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique slug for a collection within a user's scope.
|
||||
*/
|
||||
async function generateUniqueSlug(userId: string, baseName: string): Promise<string> {
|
||||
let slug = slugify(baseName);
|
||||
|
||||
// If empty after slugify, use a default
|
||||
if (!slug) {
|
||||
slug = 'collection';
|
||||
}
|
||||
|
||||
// Check if slug exists for this user
|
||||
const existing = await prisma.collection.findFirst({
|
||||
where: { userId, slug },
|
||||
});
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
// Append numbers until unique within user scope
|
||||
let counter = 1;
|
||||
while (true) {
|
||||
const candidate = `${slug.slice(0, 46)}-${counter}`; // Leave room for suffix
|
||||
const exists = await prisma.collection.findFirst({
|
||||
where: { userId, slug: candidate },
|
||||
});
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
counter++;
|
||||
if (counter > 1000) {
|
||||
throw new Error(`Could not generate unique slug for ${baseName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting username and slug population...\n');
|
||||
|
||||
// Populate usernames for users without one
|
||||
const usersWithoutUsername = await prisma.user.findMany({
|
||||
where: { username: null },
|
||||
});
|
||||
|
||||
console.log(`Found ${usersWithoutUsername.length} users without usernames`);
|
||||
|
||||
for (const user of usersWithoutUsername) {
|
||||
const username = await generateUniqueUsername(user.name || user.email.split('@')[0]);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username },
|
||||
});
|
||||
console.log(` ✓ User "${user.name || user.email}" → @${username}`);
|
||||
}
|
||||
|
||||
// Populate slugs for collections without one
|
||||
const collectionsWithoutSlug = await prisma.collection.findMany({
|
||||
where: { slug: null },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
console.log(`\nFound ${collectionsWithoutSlug.length} collections without slugs`);
|
||||
|
||||
for (const collection of collectionsWithoutSlug) {
|
||||
const slug = await generateUniqueSlug(collection.userId, collection.name);
|
||||
await prisma.collection.update({
|
||||
where: { id: collection.id },
|
||||
data: { slug },
|
||||
});
|
||||
console.log(` ✓ Collection "${collection.name}" → ${slug}`);
|
||||
}
|
||||
|
||||
console.log('\n✅ Migration complete!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Migration failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue