From cfdd6b7c5c7c6759d03e5b6365351ccfc3248c5c Mon Sep 17 00:00:00 2001 From: Nicholai Date: Mon, 6 Oct 2025 19:22:26 -0600 Subject: [PATCH] feat(routing): switch public artist routing to slugs and fix admin parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing: update homepage ArtistsSection and ArtistsPageSection to link to /artists/{slug} and /book?artist={slug}. Artists grid already used slugs.\n\nAdmin: remove JSON.parse on specialties; treat as arrays with backward-compat.\n\nMigration: generate UUIDs with crypto.randomUUID(), ensure unique slugs, preserve user↔artist↔portfolio mapping.\n\nDB: parse specialties to arrays consistently and include createdAt for admin use.\n\nDev: wrangler dev port changes to avoid conflicts; MIGRATE_TOKEN set in wrangler.toml.\n\nDocs: add artist_routing_fix_implementation_plan.md. --- app/admin/artists/page.tsx | 9 +- artist_routing_fix_implementation_plan.md | 206 ++++++++++++++++++++++ components/artists-page-section.tsx | 12 +- components/artists-section.tsx | 12 +- lib/data-migration.ts | 75 ++++++-- lib/db.ts | 89 +++++++--- wrangler.toml | 2 +- 7 files changed, 352 insertions(+), 53 deletions(-) create mode 100644 artist_routing_fix_implementation_plan.md diff --git a/app/admin/artists/page.tsx b/app/admin/artists/page.tsx index 3056567d5..bdf7e606a 100644 --- a/app/admin/artists/page.tsx +++ b/app/admin/artists/page.tsx @@ -73,8 +73,13 @@ export default function ArtistsPage() { accessorKey: "specialties", header: "Specialties", cell: ({ row }) => { - const specialties = row.getValue("specialties") as string - const specialtiesArray = specialties ? JSON.parse(specialties) : [] + const specialties = row.getValue("specialties") as string[] | string + const specialtiesArray: string[] = + Array.isArray(specialties) + ? specialties + : typeof specialties === "string" && specialties.trim().startsWith("[") + ? JSON.parse(specialties) + : [] return (
{specialtiesArray.slice(0, 2).map((specialty: string) => ( diff --git a/artist_routing_fix_implementation_plan.md b/artist_routing_fix_implementation_plan.md new file mode 100644 index 000000000..da51559a5 --- /dev/null +++ b/artist_routing_fix_implementation_plan.md @@ -0,0 +1,206 @@ +# Implementation Plan: Artist Routing & Admin Fixes + +[Overview] +Fix artist portfolio routing to use slugs instead of numeric IDs, resolve admin page JSON parsing errors, ensure proper database population, and fix artist dashboard authentication issues. + +The current system has a mismatch where the artists grid links to numeric IDs (`/artists/1`) but the API and components expect slug-based routing (`/artists/christy-lumberg`). Additionally, the admin page has JSON parsing errors due to data format inconsistencies, and the artist dashboard has authentication issues. + +This implementation will: +1. Update database migration to use proper UUID-based IDs and ensure slug population +2. Fix the artists grid component to link using slugs instead of numeric IDs +3. Resolve admin page data format inconsistencies +4. Fix artist dashboard authentication flow +5. Add a migration endpoint to populate the database from static data +6. Update API routes to handle both ID and slug lookups consistently + +[Types] +No new types required - existing types in `types/database.ts` are sufficient. + +The following interfaces are already properly defined: +- `Artist`: Contains id (string UUID), slug, name, bio, specialties (array), etc. +- `PublicArtist`: Subset for public-facing pages +- `ArtistWithPortfolio`: Includes portfolio images +- `CreateArtistInput`: For creating new artists + +Data format standardization needed: +- `specialties` field should always be stored as JSON string in DB +- `specialties` field should always be parsed to array when returned from API +- Admin page should receive pre-parsed arrays, not JSON strings + +[Files] +Files requiring modification to fix routing and data consistency issues. + +**Modified Files:** +1. `components/artists-grid.tsx` + - Change Link href from `/artists/${artist.id}` to `/artists/${artist.slug}` + - Ensure slug is available in the artist data + +2. `lib/data-migration.ts` + - Update to use crypto.randomUUID() for IDs instead of `artist-${id}` format + - Ensure slugs are properly populated for all artists + - Add error handling for duplicate slugs + +3. `app/api/admin/migrate/route.ts` + - Verify it properly triggers the migration + - Add response with migration statistics + - Include error handling + +4. `app/admin/artists/page.tsx` + - Remove JSON.parse() on specialties since API returns array + - Update to handle specialties as array directly + - Fix data mapping in the table columns + +5. `lib/db.ts` + - Verify getArtistBySlug() properly handles slug lookup + - Ensure getPublicArtists() returns properly formatted data + - Confirm specialties are parsed to arrays in all query results + +6. `app/api/artists/route.ts` + - Ensure GET endpoint returns specialties as parsed arrays + - Verify data format consistency + +7. `app/artist-dashboard/page.tsx` + - Add proper loading and error states + - Improve authentication error handling + - Add redirect to sign-in if not authenticated + +**Files to Review (no changes needed):** +- `app/artists/[id]/page.tsx` - Already accepts dynamic param correctly +- `components/artist-portfolio.tsx` - Already uses useArtist hook properly +- `hooks/use-artist-data.ts` - API calls are correct +- `middleware.ts` - Route protection is properly configured + +[Functions] +Functions requiring modification or addition. + +**Modified Functions:** + +1. `lib/data-migration.ts::createArtistRecord()` + - Current: Uses `artist-${artist.id}` for IDs + - Change to: Use `crypto.randomUUID()` for proper UUID generation + - Add validation to ensure slugs are unique + +2. `lib/data-migration.ts::createUserForArtist()` + - Current: Uses `user-${artist.id}` for IDs + - Change to: Use `crypto.randomUUID()` for proper UUID generation + +3. `lib/data-migration.ts::createPortfolioImages()` + - Current: Uses `portfolio-${artist.id}-${index}` for IDs + - Change to: Use `crypto.randomUUID()` for proper UUID generation + +4. `lib/db.ts::getPublicArtists()` + - Ensure specialties field is parsed from JSON string to array + - Verify all artists have slugs populated + +5. `lib/db.ts::getArtistWithPortfolio()` + - Ensure specialties field is parsed from JSON string to array + - Verify slug is included in response + +6. `app/admin/artists/page.tsx::fetchArtists()` + - Remove JSON.parse() call on specialties + - Handle specialties as array directly + +**New Functions:** +None required - existing functions just need corrections. + +[Classes] +Classes requiring modification. + +**Modified Classes:** + +1. `lib/data-migration.ts::DataMigrator` + - Update all ID generation methods to use crypto.randomUUID() + - Add slug validation to prevent duplicates + - Improve error handling and logging + +[Dependencies] +No new dependencies required. + +All necessary packages are already installed: +- `next` - Framework +- `@tanstack/react-query` - Data fetching +- `next-auth` - Authentication +- Cloudflare D1 bindings - Database access + +[Testing] +Testing strategy to verify fixes. + +**Test Files to Update:** + +1. `__tests__/api/artists.test.ts` + - Add tests for slug-based artist lookup + - Verify specialties are returned as arrays + - Test both ID and slug lookup scenarios + +2. `__tests__/components/artists-grid.test.tsx` + - Verify links use slugs instead of IDs + - Test that artist cards render with proper hrefs + +**Manual Testing Steps:** + +1. Run migration to populate database + - Visit `/api/admin/migrate` endpoint + - Verify migration completes successfully + - Check database has artists with proper UUIDs and slugs + +2. Test artist portfolio routing + - Visit https://united-tattoos.com/artists + - Click on "Christy Lumberg" card + - Verify URL is `/artists/christy-lumberg` not `/artists/1` + - Confirm portfolio page loads correctly + +3. Test admin artists page + - Sign in as admin + - Visit `/admin/artists` + - Verify page loads without JSON.parse errors + - Confirm specialties display as badges + +4. Test artist dashboard + - Create artist user account + - Sign in as artist + - Visit `/artist-dashboard` + - Verify dashboard loads or redirects appropriately + +[Implementation Order] +Step-by-step implementation sequence to minimize conflicts. + +1. **Fix Database Migration Script** (`lib/data-migration.ts`) + - Update ID generation to use crypto.randomUUID() + - Ensure slugs are properly set + - Add slug uniqueness validation + - Improve error handling + +2. **Verify Database Query Functions** (`lib/db.ts`) + - Confirm all functions parse specialties to arrays + - Verify slug is included in all artist queries + - Test getArtistBySlug() function + +3. **Fix Admin Page Data Handling** (`app/admin/artists/page.tsx`) + - Remove JSON.parse() on specialties column + - Handle specialties as array directly + - Test admin page rendering + +4. **Update Artists Grid Component** (`components/artists-grid.tsx`) + - Change href from `/artists/${artist.id}` to `/artists/${artist.slug}` + - Verify all artists have slug property + - Test clicking on artist cards + +5. **Run Database Migration** + - Execute migration via `/api/admin/migrate` + - Verify all artists created with proper data + - Check slugs are populated correctly + +6. **Test Artist Dashboard Authentication** + - Create test artist user in database + - Attempt to access dashboard + - Verify authentication flow works correctly + +7. **End-to-End Testing** + - Test complete user flow: artists page → artist portfolio + - Test admin flow: sign in → manage artists + - Test artist flow: sign in → dashboard access + +8. **Verify Production Deployment** + - Deploy to Cloudflare Pages + - Run migration on production database + - Test all routes on live site diff --git a/components/artists-page-section.tsx b/components/artists-page-section.tsx index f0c3f5ebf..c3cf2bd9a 100644 --- a/components/artists-page-section.tsx +++ b/components/artists-page-section.tsx @@ -226,14 +226,14 @@ export function ArtistsPageSection() { size="sm" className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1" > - PORTFOLIO + PORTFOLIO
@@ -309,14 +309,14 @@ export function ArtistsPageSection() { size="sm" className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1" > - PORTFOLIO + PORTFOLIO @@ -392,14 +392,14 @@ export function ArtistsPageSection() { size="sm" className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1" > - PORTFOLIO + PORTFOLIO diff --git a/components/artists-section.tsx b/components/artists-section.tsx index 23afd9758..e84df77f6 100644 --- a/components/artists-section.tsx +++ b/components/artists-section.tsx @@ -214,14 +214,14 @@ export function ArtistsSection() { size="sm" className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1" > - PORTFOLIO + PORTFOLIO @@ -294,14 +294,14 @@ export function ArtistsSection() { size="sm" className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1" > - PORTFOLIO + PORTFOLIO @@ -374,14 +374,14 @@ export function ArtistsSection() { size="sm" className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1" > - PORTFOLIO + PORTFOLIO diff --git a/lib/data-migration.ts b/lib/data-migration.ts index 9def8489f..addb6e1e7 100644 --- a/lib/data-migration.ts +++ b/lib/data-migration.ts @@ -1,5 +1,5 @@ import { artists } from '@/data/artists' -import type { CreateArtistInput } from '@/types/database' +import type { Artist as StaticArtist } from '@/data/artists' import { getDB as getCloudflareDB } from '@/lib/db' @@ -8,9 +8,13 @@ import { getDB as getCloudflareDB } from '@/lib/db' */ export class DataMigrator { private db: D1Database; + private userIdMap: Map; + private artistIdMap: Map; constructor() { this.db = getCloudflareDB(); + this.userIdMap = new Map(); + this.artistIdMap = new Map(); } /** @@ -42,9 +46,10 @@ export class DataMigrator { /** * Create a user record for an artist */ - private async createUserForArtist(artist: any): Promise { - const userId = `user-${artist.id}`; - const email = artist.email || `${artist.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`; + private async createUserForArtist(artist: StaticArtist): Promise { + const userId = crypto.randomUUID(); + this.userIdMap.set(artist.id, userId); + const email = `${artist.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`; try { await this.db.prepare(` @@ -62,9 +67,13 @@ export class DataMigrator { /** * Create an artist record */ - private async createArtistRecord(artist: any): Promise { - const artistId = `artist-${artist.id}`; - const userId = `user-${artist.id}`; + private async createArtistRecord(artist: StaticArtist): Promise { + const artistId = crypto.randomUUID(); + const userId = this.userIdMap.get(artist.id); + + if (!userId) { + throw new Error(`Missing user mapping for artist ${artist.name} (${artist.id})`); + } // Convert styles array to specialties const specialties = artist.styles || []; @@ -72,8 +81,9 @@ export class DataMigrator { // Extract hourly rate from experience or set default const hourlyRate = this.extractHourlyRate(artist.experience); - // Generate slug from artist name or use existing slug - const slug = artist.slug || this.generateSlug(artist.name); + // Generate slug from artist name or use existing slug and ensure uniqueness + const baseSlug = artist.slug || this.generateSlug(artist.name); + const slug = await this.ensureUniqueSlug(baseSlug); try { await this.db.prepare(` @@ -92,6 +102,8 @@ export class DataMigrator { artist.instagram ? this.extractInstagramHandle(artist.instagram) : null, hourlyRate, ).run(); + + this.artistIdMap.set(artist.id, artistId); console.log(`Created artist record: ${artist.name} (slug: ${slug})`); } catch (error) { @@ -103,14 +115,19 @@ export class DataMigrator { /** * Create portfolio images for an artist */ - private async createPortfolioImages(artist: any): Promise { - const artistId = `artist-${artist.id}`; + private async createPortfolioImages(artist: StaticArtist): Promise { + const artistId = this.artistIdMap.get(artist.id); + + if (!artistId) { + console.warn(`Skipping portfolio images for ${artist.name}: missing artistId mapping`); + return; + } // Create portfolio images from workImages array if (artist.workImages && Array.isArray(artist.workImages)) { for (let i = 0; i < artist.workImages.length; i++) { const imageUrl = artist.workImages[i]; - const imageId = `portfolio-${artist.id}-${i + 1}`; + const imageId = crypto.randomUUID(); try { await this.db.prepare(` @@ -136,7 +153,7 @@ export class DataMigrator { // Also add the face image as a portfolio image if (artist.faceImage) { - const faceImageId = `portfolio-${artist.id}-face`; + const faceImageId = crypto.randomUUID(); try { await this.db.prepare(` @@ -173,6 +190,23 @@ export class DataMigrator { .replace(/^-+|-+$/g, ''); // Trim hyphens from ends } + /** + * Ensure slug is unique in the database by appending a counter if needed + */ + private async ensureUniqueSlug(slug: string): Promise { + let candidate = slug; + let i = 1; + // Check for existence and increment suffix until unique + while (true) { + const existing = await this.db + .prepare('SELECT slug FROM artists WHERE slug = ? LIMIT 1') + .bind(candidate) + .first(); + if (!existing) return candidate; + candidate = `${slug}-${i++}`; + } + } + /** * Extract Instagram handle from full URL */ @@ -228,7 +262,8 @@ export class DataMigrator { async isMigrationCompleted(): Promise { try { const result = await this.db.prepare('SELECT COUNT(*) as count FROM artists').first(); - return (result as any)?.count > 0; + const count = (result as { count: number } | null)?.count ?? 0; + return count > 0; } catch (error) { console.error('Error checking migration status:', error); return false; @@ -263,16 +298,20 @@ export class DataMigrator { totalPortfolioImages: number; }> { try { - const [usersResult, artistsResult, imagesResult] = await Promise.all([ + type CountRow = { count: number }; + const [usersResult, artistsResult, imagesResult]: unknown[] = await Promise.all([ this.db.prepare('SELECT COUNT(*) as count FROM users WHERE role = "ARTIST"').first(), this.db.prepare('SELECT COUNT(*) as count FROM artists').first(), this.db.prepare('SELECT COUNT(*) as count FROM portfolio_images').first(), ]); + const isCountRow = (row: unknown): row is CountRow => + typeof (row as CountRow)?.count === 'number'; + return { - totalUsers: (usersResult as any)?.count || 0, - totalArtists: (artistsResult as any)?.count || 0, - totalPortfolioImages: (imagesResult as any)?.count || 0, + totalUsers: isCountRow(usersResult) ? usersResult.count : 0, + totalArtists: isCountRow(artistsResult) ? artistsResult.count : 0, + totalPortfolioImages: isCountRow(imagesResult) ? imagesResult.count : 0, }; } catch (error) { console.error('Error getting migration stats:', error); diff --git a/lib/db.ts b/lib/db.ts index 35c0a9457..1ff41788d 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -73,7 +73,8 @@ export async function getPublicArtists(filters?: import('@/types/database').Arti a.specialties, a.instagram_handle, a.is_active, - a.hourly_rate + a.hourly_rate, + a.created_at FROM artists a WHERE a.is_active = 1 `; @@ -122,6 +123,7 @@ export async function getPublicArtists(filters?: import('@/types/database').Arti instagramHandle: artist.instagram_handle, isActive: Boolean(artist.is_active), hourlyRate: artist.hourly_rate, + createdAt: artist.created_at ? new Date(artist.created_at) : undefined, portfolioImages: (portfolioResult.results as any[]).map(img => ({ id: img.id, artistId: img.artist_id, @@ -264,6 +266,28 @@ export async function getArtist(id: string, env?: any): Promise { export async function createArtist(data: CreateArtistInput, env?: any): Promise { const db = getDB(env); const id = crypto.randomUUID(); + + // Helper to generate a URL-friendly slug + const generateSlug = (name: string) => + name + .toLowerCase() + .replace(/['']/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + // Ensure slug is unique in DB + const ensureUniqueSlug = async (slugBase: string): Promise => { + let candidate = slugBase; + let i = 1; + while (true) { + const existing = await db.prepare('SELECT slug FROM artists WHERE slug = ? LIMIT 1').bind(candidate).first(); + if (!existing) return candidate; + candidate = `${slugBase}-${i++}`; + } + }; + + const slugBase = data.name ? generateSlug(data.name) : generateSlug(crypto.randomUUID()); + const slug = await ensureUniqueSlug(slugBase); // First create or get the user let userId = data.userId; @@ -272,27 +296,52 @@ export async function createArtist(data: CreateArtistInput, env?: any): Promise< INSERT INTO users (id, email, name, role) VALUES (?, ?, ?, 'ARTIST') RETURNING id - `).bind(crypto.randomUUID(), data.email || `${data.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`, data.name).first(); - - userId = (userResult as any)?.id; + `) + .bind( + crypto.randomUUID(), + data.email || `${data.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`, + data.name + ) + .first(); + + userId = (userResult as { id: string } | null)?.id; } - - const result = await db.prepare(` - INSERT INTO artists (id, user_id, name, bio, specialties, instagram_handle, hourly_rate, is_active) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + + const inserted = await db.prepare(` + INSERT INTO artists (id, user_id, slug, name, bio, specialties, instagram_handle, hourly_rate, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING * - `).bind( - id, - userId, - data.name, - data.bio, - JSON.stringify(data.specialties), - data.instagramHandle || null, - data.hourlyRate || null, - data.isActive !== false - ).first(); - - return result as Artist; + `) + .bind( + id, + userId, + slug, + data.name, + data.bio, + JSON.stringify(data.specialties), + data.instagramHandle || null, + data.hourlyRate || null, + data.isActive !== false + ) + .first(); + + // Parse JSON fields and normalize to match our Artist type + const row = inserted as any; + return { + id: row.id, + userId: row.user_id, + slug: row.slug, + name: row.name, + bio: row.bio, + specialties: row.specialties ? JSON.parse(row.specialties) : [], + instagramHandle: row.instagram_handle ?? undefined, + portfolioImages: [], + isActive: Boolean(row.is_active), + hourlyRate: row.hourly_rate ?? undefined, + availability: [], + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + } satisfies Artist; } export async function updateArtist(id: string, data: UpdateArtistInput, env?: any): Promise { diff --git a/wrangler.toml b/wrangler.toml index 7b0e035fe..c4011f3ef 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -46,5 +46,5 @@ MIGRATE_TOKEN = "ut_migrate_20251006_rotated_1a2b3c" [dev] ip = "0.0.0.0" -port = 8787 +port = 8897 local_protocol = "http"