Implements backend infrastructure for loading artist profiles from Cloudflare D1 database instead of static data. Database Changes: - Add slug column migration for SEO-friendly URLs (0001_add_artist_slug.sql) - Enhanced data migration script with slug generation - Support for all artist fields from data/artists.ts Type Definitions: - Add slug field to Artist interface - Create ArtistWithPortfolio type for full artist data - Create PublicArtist type for sanitized API responses - Add ArtistFilters type for query parameters - Add ArtistDashboardStats for analytics Database Functions (lib/db.ts): - getPublicArtists() - fetch active artists with portfolio and filtering - getArtistWithPortfolio() - fetch single artist with full portfolio - getArtistBySlug() - fetch by URL-friendly slug - getArtistByUserId() - fetch by user ID for dashboard - Enhanced getArtists() with JSON parsing API Endpoints: - Updated GET /api/artists - filtering, pagination, portfolio images - Created GET /api/artists/[id] - fetch by ID or slug - Created PUT /api/artists/[id] - update with authorization - Created DELETE /api/artists/[id] - soft delete (admin only) - Created GET /api/artists/me - current artist profile React Hooks (hooks/use-artist-data.ts): - useArtists() - fetch with filtering - useArtist() - fetch single artist - useCurrentArtist() - logged-in artist - useUpdateArtist(), useCreateArtist(), useDeleteArtist() - mutations Frontend Components: - Refactored artists-grid.tsx to use API with loading/error states - Use database field names (slug, specialties, portfolioImages) - Display profile images from portfolio - Client-side filtering by specialty Files Modified: - sql/migrations/0001_add_artist_slug.sql (new) - types/database.ts (enhanced) - lib/data-migration.ts (enhanced) - lib/db.ts (enhanced) - app/api/artists/route.ts (updated) - app/api/artists/[id]/route.ts (new) - app/api/artists/me/route.ts (new) - hooks/use-artist-data.ts (new) - components/artists-grid.tsx (refactored) Remaining work: Artist portfolio page, artist dashboard, admin enhancements Ref: artist_profile_refactor_implementation_plan.md
324 lines
9.6 KiB
TypeScript
324 lines
9.6 KiB
TypeScript
import { artists } from '@/data/artists'
|
|
import type { CreateArtistInput } from '@/types/database'
|
|
|
|
// Type for Cloudflare D1 database binding
|
|
interface Env {
|
|
DB: D1Database;
|
|
}
|
|
|
|
// Get the database instance from the environment
|
|
function getDB(): D1Database {
|
|
// @ts-ignore - This will be available in the Cloudflare Workers runtime
|
|
return globalThis.DB || (globalThis as any).env?.DB;
|
|
}
|
|
|
|
/**
|
|
* Migration utility to populate D1 database with existing artist data
|
|
*/
|
|
export class DataMigrator {
|
|
private db: D1Database;
|
|
|
|
constructor() {
|
|
this.db = getDB();
|
|
}
|
|
|
|
/**
|
|
* Migrate all artist data from data/artists.ts to D1 database
|
|
*/
|
|
async migrateArtistData(): Promise<void> {
|
|
console.log('Starting artist data migration...');
|
|
|
|
try {
|
|
// First, create users for each artist
|
|
const userInserts = artists.map(artist => this.createUserForArtist(artist));
|
|
await Promise.all(userInserts);
|
|
|
|
// Then create artist records
|
|
const artistInserts = artists.map(artist => this.createArtistRecord(artist));
|
|
await Promise.all(artistInserts);
|
|
|
|
// Finally, create portfolio images
|
|
const portfolioInserts = artists.map(artist => this.createPortfolioImages(artist));
|
|
await Promise.all(portfolioInserts);
|
|
|
|
console.log(`Successfully migrated ${artists.length} artists to database`);
|
|
} catch (error) {
|
|
console.error('Error during artist data migration:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a user record for an artist
|
|
*/
|
|
private async createUserForArtist(artist: any): Promise<void> {
|
|
const userId = `user-${artist.id}`;
|
|
const email = artist.email || `${artist.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`;
|
|
|
|
try {
|
|
await this.db.prepare(`
|
|
INSERT OR IGNORE INTO users (id, email, name, role, created_at, updated_at)
|
|
VALUES (?, ?, ?, 'ARTIST', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
`).bind(userId, email, artist.name).run();
|
|
|
|
console.log(`Created user for artist: ${artist.name}`);
|
|
} catch (error) {
|
|
console.error(`Error creating user for artist ${artist.name}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an artist record
|
|
*/
|
|
private async createArtistRecord(artist: any): Promise<void> {
|
|
const artistId = `artist-${artist.id}`;
|
|
const userId = `user-${artist.id}`;
|
|
|
|
// Convert styles array to specialties
|
|
const specialties = artist.styles || [];
|
|
|
|
// 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);
|
|
|
|
try {
|
|
await this.db.prepare(`
|
|
INSERT OR IGNORE INTO artists (
|
|
id, user_id, slug, name, bio, specialties, instagram_handle,
|
|
hourly_rate, is_active, created_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
`).bind(
|
|
artistId,
|
|
userId,
|
|
slug,
|
|
artist.name,
|
|
artist.bio,
|
|
JSON.stringify(specialties),
|
|
artist.instagram ? this.extractInstagramHandle(artist.instagram) : null,
|
|
hourlyRate,
|
|
).run();
|
|
|
|
console.log(`Created artist record: ${artist.name} (slug: ${slug})`);
|
|
} catch (error) {
|
|
console.error(`Error creating artist record for ${artist.name}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create portfolio images for an artist
|
|
*/
|
|
private async createPortfolioImages(artist: any): Promise<void> {
|
|
const artistId = `artist-${artist.id}`;
|
|
|
|
// 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}`;
|
|
|
|
try {
|
|
await this.db.prepare(`
|
|
INSERT OR IGNORE INTO portfolio_images (
|
|
id, artist_id, url, caption, tags, order_index,
|
|
is_public, created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
|
`).bind(
|
|
imageId,
|
|
artistId,
|
|
imageUrl,
|
|
`${artist.name} - Portfolio Image ${i + 1}`,
|
|
JSON.stringify(artist.styles || []),
|
|
i
|
|
).run();
|
|
|
|
} catch (error) {
|
|
console.error(`Error creating portfolio image for ${artist.name}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also add the face image as a portfolio image
|
|
if (artist.faceImage) {
|
|
const faceImageId = `portfolio-${artist.id}-face`;
|
|
|
|
try {
|
|
await this.db.prepare(`
|
|
INSERT OR IGNORE INTO portfolio_images (
|
|
id, artist_id, url, caption, tags, order_index,
|
|
is_public, created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
|
`).bind(
|
|
faceImageId,
|
|
artistId,
|
|
artist.faceImage,
|
|
`${artist.name} - Profile Photo`,
|
|
JSON.stringify(['profile']),
|
|
-1 // Face image gets negative order to appear first
|
|
).run();
|
|
|
|
} catch (error) {
|
|
console.error(`Error creating face image for ${artist.name}:`, error);
|
|
}
|
|
}
|
|
|
|
console.log(`Created portfolio images for: ${artist.name}`);
|
|
}
|
|
|
|
/**
|
|
* Generate URL-friendly slug from artist name
|
|
*/
|
|
private generateSlug(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/['']/g, '') // Remove apostrophes
|
|
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
|
.replace(/^-+|-+$/g, ''); // Trim hyphens from ends
|
|
}
|
|
|
|
/**
|
|
* Extract Instagram handle from full URL
|
|
*/
|
|
private extractInstagramHandle(instagramUrl: string): string | null {
|
|
if (!instagramUrl) return null;
|
|
|
|
// Extract handle from Instagram URL
|
|
const match = instagramUrl.match(/instagram\.com\/([^\/\?]+)/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Extract or estimate hourly rate based on experience
|
|
*/
|
|
private extractHourlyRate(experience: string): number {
|
|
// Default rates based on experience level
|
|
const experienceRates: { [key: string]: number } = {
|
|
'Apprentice': 80,
|
|
'5 years': 120,
|
|
'6 years': 130,
|
|
'7 years': 140,
|
|
'8 years': 150,
|
|
'10 years': 170,
|
|
'12+ years': 200,
|
|
'22+ years': 250,
|
|
'30+ years': 300,
|
|
};
|
|
|
|
// Try to find exact match first
|
|
if (experienceRates[experience]) {
|
|
return experienceRates[experience];
|
|
}
|
|
|
|
// Extract years from experience string and estimate rate
|
|
const yearMatch = experience.match(/(\d+)/);
|
|
if (yearMatch) {
|
|
const years = parseInt(yearMatch[1]);
|
|
if (years <= 2) return 80;
|
|
if (years <= 5) return 120;
|
|
if (years <= 10) return 150;
|
|
if (years <= 15) return 180;
|
|
if (years <= 20) return 220;
|
|
return 250;
|
|
}
|
|
|
|
// Default rate for unknown experience
|
|
return 120;
|
|
}
|
|
|
|
/**
|
|
* Check if migration has already been completed
|
|
*/
|
|
async isMigrationCompleted(): Promise<boolean> {
|
|
try {
|
|
const result = await this.db.prepare('SELECT COUNT(*) as count FROM artists').first();
|
|
return (result as any)?.count > 0;
|
|
} catch (error) {
|
|
console.error('Error checking migration status:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all migrated data (for testing purposes)
|
|
*/
|
|
async clearMigratedData(): Promise<void> {
|
|
console.log('Clearing migrated data...');
|
|
|
|
try {
|
|
// Delete in reverse order due to foreign key constraints
|
|
await this.db.prepare('DELETE FROM portfolio_images').run();
|
|
await this.db.prepare('DELETE FROM artists').run();
|
|
await this.db.prepare('DELETE FROM users WHERE role = "ARTIST"').run();
|
|
|
|
console.log('Successfully cleared migrated data');
|
|
} catch (error) {
|
|
console.error('Error clearing migrated data:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get migration statistics
|
|
*/
|
|
async getMigrationStats(): Promise<{
|
|
totalUsers: number;
|
|
totalArtists: number;
|
|
totalPortfolioImages: number;
|
|
}> {
|
|
try {
|
|
const [usersResult, artistsResult, imagesResult] = 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(),
|
|
]);
|
|
|
|
return {
|
|
totalUsers: (usersResult as any)?.count || 0,
|
|
totalArtists: (artistsResult as any)?.count || 0,
|
|
totalPortfolioImages: (imagesResult as any)?.count || 0,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error getting migration stats:', error);
|
|
return { totalUsers: 0, totalArtists: 0, totalPortfolioImages: 0 };
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience function to run migration
|
|
*/
|
|
export async function migrateArtistData(): Promise<void> {
|
|
const migrator = new DataMigrator();
|
|
|
|
// Check if migration has already been completed
|
|
const isCompleted = await migrator.isMigrationCompleted();
|
|
if (isCompleted) {
|
|
console.log('Migration already completed. Skipping...');
|
|
return;
|
|
}
|
|
|
|
await migrator.migrateArtistData();
|
|
}
|
|
|
|
/**
|
|
* Convenience function to get migration stats
|
|
*/
|
|
export async function getMigrationStats() {
|
|
const migrator = new DataMigrator();
|
|
return await migrator.getMigrationStats();
|
|
}
|
|
|
|
/**
|
|
* Convenience function to clear migrated data (for development/testing)
|
|
*/
|
|
export async function clearMigratedData(): Promise<void> {
|
|
const migrator = new DataMigrator();
|
|
await migrator.clearMigratedData();
|
|
}
|