feat(routing): switch public artist routing to slugs and fix admin parsing
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.
This commit is contained in:
parent
aa23c905bf
commit
aae48e1fa9
@ -73,8 +73,13 @@ export default function ArtistsPage() {
|
|||||||
accessorKey: "specialties",
|
accessorKey: "specialties",
|
||||||
header: "Specialties",
|
header: "Specialties",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const specialties = row.getValue("specialties") as string
|
const specialties = row.getValue("specialties") as string[] | string
|
||||||
const specialtiesArray = specialties ? JSON.parse(specialties) : []
|
const specialtiesArray: string[] =
|
||||||
|
Array.isArray(specialties)
|
||||||
|
? specialties
|
||||||
|
: typeof specialties === "string" && specialties.trim().startsWith("[")
|
||||||
|
? JSON.parse(specialties)
|
||||||
|
: []
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{specialtiesArray.slice(0, 2).map((specialty: string) => (
|
{specialtiesArray.slice(0, 2).map((specialty: string) => (
|
||||||
|
|||||||
206
artist_routing_fix_implementation_plan.md
Normal file
206
artist_routing_fix_implementation_plan.md
Normal file
@ -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
|
||||||
@ -226,14 +226,14 @@ export function ArtistsPageSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
|
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href="/book">BOOK</Link>
|
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -309,14 +309,14 @@ export function ArtistsPageSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
|
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href="/book">BOOK</Link>
|
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -392,14 +392,14 @@ export function ArtistsPageSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
|
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href="/book">BOOK</Link>
|
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -214,14 +214,14 @@ export function ArtistsSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
|
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href="/book">BOOK</Link>
|
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -294,14 +294,14 @@ export function ArtistsSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
|
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href="/book">BOOK</Link>
|
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -374,14 +374,14 @@ export function ArtistsSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
|
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
|
||||||
>
|
>
|
||||||
<Link href="/book">BOOK</Link>
|
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { artists } from '@/data/artists'
|
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'
|
import { getDB as getCloudflareDB } from '@/lib/db'
|
||||||
|
|
||||||
|
|
||||||
@ -8,9 +8,13 @@ import { getDB as getCloudflareDB } from '@/lib/db'
|
|||||||
*/
|
*/
|
||||||
export class DataMigrator {
|
export class DataMigrator {
|
||||||
private db: D1Database;
|
private db: D1Database;
|
||||||
|
private userIdMap: Map<number, string>;
|
||||||
|
private artistIdMap: Map<number, string>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.db = getCloudflareDB();
|
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
|
* Create a user record for an artist
|
||||||
*/
|
*/
|
||||||
private async createUserForArtist(artist: any): Promise<void> {
|
private async createUserForArtist(artist: StaticArtist): Promise<void> {
|
||||||
const userId = `user-${artist.id}`;
|
const userId = crypto.randomUUID();
|
||||||
const email = artist.email || `${artist.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`;
|
this.userIdMap.set(artist.id, userId);
|
||||||
|
const email = `${artist.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.db.prepare(`
|
await this.db.prepare(`
|
||||||
@ -62,9 +67,13 @@ export class DataMigrator {
|
|||||||
/**
|
/**
|
||||||
* Create an artist record
|
* Create an artist record
|
||||||
*/
|
*/
|
||||||
private async createArtistRecord(artist: any): Promise<void> {
|
private async createArtistRecord(artist: StaticArtist): Promise<void> {
|
||||||
const artistId = `artist-${artist.id}`;
|
const artistId = crypto.randomUUID();
|
||||||
const userId = `user-${artist.id}`;
|
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
|
// Convert styles array to specialties
|
||||||
const specialties = artist.styles || [];
|
const specialties = artist.styles || [];
|
||||||
@ -72,8 +81,9 @@ export class DataMigrator {
|
|||||||
// Extract hourly rate from experience or set default
|
// Extract hourly rate from experience or set default
|
||||||
const hourlyRate = this.extractHourlyRate(artist.experience);
|
const hourlyRate = this.extractHourlyRate(artist.experience);
|
||||||
|
|
||||||
// Generate slug from artist name or use existing slug
|
// Generate slug from artist name or use existing slug and ensure uniqueness
|
||||||
const slug = artist.slug || this.generateSlug(artist.name);
|
const baseSlug = artist.slug || this.generateSlug(artist.name);
|
||||||
|
const slug = await this.ensureUniqueSlug(baseSlug);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.db.prepare(`
|
await this.db.prepare(`
|
||||||
@ -93,6 +103,8 @@ export class DataMigrator {
|
|||||||
hourlyRate,
|
hourlyRate,
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
|
this.artistIdMap.set(artist.id, artistId);
|
||||||
|
|
||||||
console.log(`Created artist record: ${artist.name} (slug: ${slug})`);
|
console.log(`Created artist record: ${artist.name} (slug: ${slug})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error creating artist record for ${artist.name}:`, error);
|
console.error(`Error creating artist record for ${artist.name}:`, error);
|
||||||
@ -103,14 +115,19 @@ export class DataMigrator {
|
|||||||
/**
|
/**
|
||||||
* Create portfolio images for an artist
|
* Create portfolio images for an artist
|
||||||
*/
|
*/
|
||||||
private async createPortfolioImages(artist: any): Promise<void> {
|
private async createPortfolioImages(artist: StaticArtist): Promise<void> {
|
||||||
const artistId = `artist-${artist.id}`;
|
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
|
// Create portfolio images from workImages array
|
||||||
if (artist.workImages && Array.isArray(artist.workImages)) {
|
if (artist.workImages && Array.isArray(artist.workImages)) {
|
||||||
for (let i = 0; i < artist.workImages.length; i++) {
|
for (let i = 0; i < artist.workImages.length; i++) {
|
||||||
const imageUrl = artist.workImages[i];
|
const imageUrl = artist.workImages[i];
|
||||||
const imageId = `portfolio-${artist.id}-${i + 1}`;
|
const imageId = crypto.randomUUID();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.db.prepare(`
|
await this.db.prepare(`
|
||||||
@ -136,7 +153,7 @@ export class DataMigrator {
|
|||||||
|
|
||||||
// Also add the face image as a portfolio image
|
// Also add the face image as a portfolio image
|
||||||
if (artist.faceImage) {
|
if (artist.faceImage) {
|
||||||
const faceImageId = `portfolio-${artist.id}-face`;
|
const faceImageId = crypto.randomUUID();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.db.prepare(`
|
await this.db.prepare(`
|
||||||
@ -173,6 +190,23 @@ export class DataMigrator {
|
|||||||
.replace(/^-+|-+$/g, ''); // Trim hyphens from ends
|
.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<string> {
|
||||||
|
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
|
* Extract Instagram handle from full URL
|
||||||
*/
|
*/
|
||||||
@ -228,7 +262,8 @@ export class DataMigrator {
|
|||||||
async isMigrationCompleted(): Promise<boolean> {
|
async isMigrationCompleted(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await this.db.prepare('SELECT COUNT(*) as count FROM artists').first();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error checking migration status:', error);
|
console.error('Error checking migration status:', error);
|
||||||
return false;
|
return false;
|
||||||
@ -263,16 +298,20 @@ export class DataMigrator {
|
|||||||
totalPortfolioImages: number;
|
totalPortfolioImages: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
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 users WHERE role = "ARTIST"').first(),
|
||||||
this.db.prepare('SELECT COUNT(*) as count FROM artists').first(),
|
this.db.prepare('SELECT COUNT(*) as count FROM artists').first(),
|
||||||
this.db.prepare('SELECT COUNT(*) as count FROM portfolio_images').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 {
|
return {
|
||||||
totalUsers: (usersResult as any)?.count || 0,
|
totalUsers: isCountRow(usersResult) ? usersResult.count : 0,
|
||||||
totalArtists: (artistsResult as any)?.count || 0,
|
totalArtists: isCountRow(artistsResult) ? artistsResult.count : 0,
|
||||||
totalPortfolioImages: (imagesResult as any)?.count || 0,
|
totalPortfolioImages: isCountRow(imagesResult) ? imagesResult.count : 0,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting migration stats:', error);
|
console.error('Error getting migration stats:', error);
|
||||||
|
|||||||
83
lib/db.ts
83
lib/db.ts
@ -73,7 +73,8 @@ export async function getPublicArtists(filters?: import('@/types/database').Arti
|
|||||||
a.specialties,
|
a.specialties,
|
||||||
a.instagram_handle,
|
a.instagram_handle,
|
||||||
a.is_active,
|
a.is_active,
|
||||||
a.hourly_rate
|
a.hourly_rate,
|
||||||
|
a.created_at
|
||||||
FROM artists a
|
FROM artists a
|
||||||
WHERE a.is_active = 1
|
WHERE a.is_active = 1
|
||||||
`;
|
`;
|
||||||
@ -122,6 +123,7 @@ export async function getPublicArtists(filters?: import('@/types/database').Arti
|
|||||||
instagramHandle: artist.instagram_handle,
|
instagramHandle: artist.instagram_handle,
|
||||||
isActive: Boolean(artist.is_active),
|
isActive: Boolean(artist.is_active),
|
||||||
hourlyRate: artist.hourly_rate,
|
hourlyRate: artist.hourly_rate,
|
||||||
|
createdAt: artist.created_at ? new Date(artist.created_at) : undefined,
|
||||||
portfolioImages: (portfolioResult.results as any[]).map(img => ({
|
portfolioImages: (portfolioResult.results as any[]).map(img => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
artistId: img.artist_id,
|
artistId: img.artist_id,
|
||||||
@ -265,6 +267,28 @@ export async function createArtist(data: CreateArtistInput, env?: any): Promise<
|
|||||||
const db = getDB(env);
|
const db = getDB(env);
|
||||||
const id = crypto.randomUUID();
|
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<string> => {
|
||||||
|
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
|
// First create or get the user
|
||||||
let userId = data.userId;
|
let userId = data.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@ -272,27 +296,52 @@ export async function createArtist(data: CreateArtistInput, env?: any): Promise<
|
|||||||
INSERT INTO users (id, email, name, role)
|
INSERT INTO users (id, email, name, role)
|
||||||
VALUES (?, ?, ?, 'ARTIST')
|
VALUES (?, ?, ?, 'ARTIST')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`).bind(crypto.randomUUID(), data.email || `${data.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`, data.name).first();
|
`)
|
||||||
|
.bind(
|
||||||
|
crypto.randomUUID(),
|
||||||
|
data.email || `${data.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`,
|
||||||
|
data.name
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
userId = (userResult as any)?.id;
|
userId = (userResult as { id: string } | null)?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await db.prepare(`
|
const inserted = await db.prepare(`
|
||||||
INSERT INTO artists (id, user_id, name, bio, specialties, instagram_handle, hourly_rate, is_active)
|
INSERT INTO artists (id, user_id, slug, name, bio, specialties, instagram_handle, hourly_rate, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`).bind(
|
`)
|
||||||
id,
|
.bind(
|
||||||
userId,
|
id,
|
||||||
data.name,
|
userId,
|
||||||
data.bio,
|
slug,
|
||||||
JSON.stringify(data.specialties),
|
data.name,
|
||||||
data.instagramHandle || null,
|
data.bio,
|
||||||
data.hourlyRate || null,
|
JSON.stringify(data.specialties),
|
||||||
data.isActive !== false
|
data.instagramHandle || null,
|
||||||
).first();
|
data.hourlyRate || null,
|
||||||
|
data.isActive !== false
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
return result as Artist;
|
// 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<Artist> {
|
export async function updateArtist(id: string, data: UpdateArtistInput, env?: any): Promise<Artist> {
|
||||||
|
|||||||
@ -46,5 +46,5 @@ MIGRATE_TOKEN = "ut_migrate_20251006_rotated_1a2b3c"
|
|||||||
|
|
||||||
[dev]
|
[dev]
|
||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 8787
|
port = 8897
|
||||||
local_protocol = "http"
|
local_protocol = "http"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user