{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"