feat(database): redesign album system with content support and geolocation

- Add album content field for rich text/structured content
- Add geolocation support for albums with position and zoom level
- Remove direct photo-album relationship in favor of MediaAlbum join table
- Support many-to-many relationships between media and albums
- Add Album relation to Universe model for better organization

This enables albums to have rich content beyond just photos and supports
geographic data for location-based albums.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-24 01:10:54 +01:00
parent bb434d40dc
commit 38b62168e9
3 changed files with 182 additions and 146 deletions

View file

@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "Album" ADD COLUMN "content" JSONB;
-- CreateTable
CREATE TABLE "GeoLocation" (
"id" SERIAL NOT NULL,
"albumId" INTEGER NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"title" VARCHAR(255) NOT NULL,
"description" TEXT,
"markerColor" VARCHAR(7),
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GeoLocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "GeoLocation_albumId_idx" ON "GeoLocation"("albumId");
-- AddForeignKey
ALTER TABLE "GeoLocation" ADD CONSTRAINT "GeoLocation_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,24 @@
-- Step 1: Migrate any remaining direct photo-album relationships to AlbumMedia
INSERT INTO "AlbumMedia" ("albumId", "mediaId", "displayOrder", "createdAt")
SELECT DISTINCT
p."albumId",
p."mediaId",
p."displayOrder",
p."createdAt"
FROM "Photo" p
WHERE p."albumId" IS NOT NULL
AND p."mediaId" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "AlbumMedia" am
WHERE am."albumId" = p."albumId"
AND am."mediaId" = p."mediaId"
);
-- Step 2: Drop the foreign key constraint
ALTER TABLE "Photo" DROP CONSTRAINT IF EXISTS "Photo_albumId_fkey";
-- Step 3: Drop the albumId column from Photo table
ALTER TABLE "Photo" DROP COLUMN IF EXISTS "albumId";
-- Step 4: Drop the index on albumId
DROP INDEX IF EXISTS "Photo_albumId_idx";

View file

@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -10,181 +7,172 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// Projects table (for /work)
model Project { model Project {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String @unique @db.VarChar(255) slug String @unique @db.VarChar(255)
title String @db.VarChar(255) title String @db.VarChar(255)
subtitle String? @db.VarChar(255) subtitle String? @db.VarChar(255)
description String? @db.Text description String?
year Int year Int
client String? @db.VarChar(255) client String? @db.VarChar(255)
role String? @db.VarChar(255) role String? @db.VarChar(255)
featuredImage String? @db.VarChar(500) featuredImage String? @db.VarChar(500)
logoUrl String? @db.VarChar(500) gallery Json?
gallery Json? // Array of image URLs externalUrl String? @db.VarChar(500)
externalUrl String? @db.VarChar(500) caseStudyContent Json?
caseStudyContent Json? // BlockNote JSON format displayOrder Int @default(0)
backgroundColor String? @db.VarChar(50) // For project card styling status String @default("draft") @db.VarChar(50)
highlightColor String? @db.VarChar(50) // For project card accent publishedAt DateTime?
projectType String @default("work") @db.VarChar(50) // "work" or "labs" createdAt DateTime @default(now())
displayOrder Int @default(0) updatedAt DateTime @updatedAt
status String @default("draft") @db.VarChar(50) // "draft", "published", "list-only", "password-protected" backgroundColor String? @db.VarChar(50)
password String? @db.VarChar(255) // Required when status is "password-protected" highlightColor String? @db.VarChar(50)
publishedAt DateTime? logoUrl String? @db.VarChar(500)
createdAt DateTime @default(now()) password String? @db.VarChar(255)
updatedAt DateTime @updatedAt projectType String @default("work") @db.VarChar(50)
@@index([slug]) @@index([slug])
@@index([status]) @@index([status])
} }
// Posts table (for /universe)
model Post { model Post {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String @unique @db.VarChar(255) slug String @unique @db.VarChar(255)
postType String @db.VarChar(50) // post, essay postType String @db.VarChar(50)
title String? @db.VarChar(255) // Optional for post type title String? @db.VarChar(255)
content Json? // JSON content for posts and essays content Json?
featuredImage String? @db.VarChar(500)
featuredImage String? @db.VarChar(500) tags Json?
attachments Json? // Array of media IDs for photo attachments status String @default("draft") @db.VarChar(50)
tags Json? // Array of tags publishedAt DateTime?
status String @default("draft") @db.VarChar(50) createdAt DateTime @default(now())
publishedAt DateTime? updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) attachments Json?
updatedAt DateTime @updatedAt
@@index([slug]) @@index([slug])
@@index([status]) @@index([status])
@@index([postType]) @@index([postType])
} }
// Albums table
model Album { model Album {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String @unique @db.VarChar(255) slug String @unique @db.VarChar(255)
title String @db.VarChar(255) title String @db.VarChar(255)
description String? @db.Text description String?
date DateTime? date DateTime?
location String? @db.VarChar(255) location String? @db.VarChar(255)
coverPhotoId Int? coverPhotoId Int?
isPhotography Boolean @default(false) // Show in photos experience status String @default("draft") @db.VarChar(50)
status String @default("draft") @db.VarChar(50) showInUniverse Boolean @default(false)
showInUniverse Boolean @default(false) createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt content Json?
publishedAt DateTime?
// Relations media AlbumMedia[]
photos Photo[] // Will be removed after migration geoLocations GeoLocation[]
media AlbumMedia[]
@@index([slug]) @@index([slug])
@@index([status]) @@index([status])
} }
// Photos table
model Photo { model Photo {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
albumId Int? filename String @db.VarChar(255)
mediaId Int? // Reference to the Media item url String @db.VarChar(500)
filename String @db.VarChar(255) thumbnailUrl String? @db.VarChar(500)
url String @db.VarChar(500) width Int?
thumbnailUrl String? @db.VarChar(500) height Int?
width Int? exifData Json?
height Int? caption String?
dominantColor String? @db.VarChar(7) // Hex color like #FFFFFF displayOrder Int @default(0)
colors Json? // Full color palette from Cloudinary slug String? @unique @db.VarChar(255)
aspectRatio Float? // Width/height ratio title String? @db.VarChar(255)
exifData Json? description String?
caption String? @db.Text status String @default("draft") @db.VarChar(50)
displayOrder Int @default(0) publishedAt DateTime?
showInPhotos Boolean @default(true)
// Individual publishing support createdAt DateTime @default(now())
slug String? @unique @db.VarChar(255) mediaId Int?
title String? @db.VarChar(255) dominantColor String? @db.VarChar(7)
description String? @db.Text colors Json?
status String @default("draft") @db.VarChar(50) aspectRatio Float?
publishedAt DateTime? media Media? @relation(fields: [mediaId], references: [id])
showInPhotos Boolean @default(true)
createdAt DateTime @default(now())
// Relations
album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade)
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
@@index([slug]) @@index([slug])
@@index([status]) @@index([status])
@@index([mediaId]) @@index([mediaId])
} }
// Media table (general uploads)
model Media { model Media {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
filename String @db.VarChar(255) filename String @db.VarChar(255)
originalName String? @db.VarChar(255) // Original filename from user (optional for backward compatibility) mimeType String @db.VarChar(100)
mimeType String @db.VarChar(100) size Int
size Int url String
url String @db.Text thumbnailUrl String?
thumbnailUrl String? @db.Text width Int?
width Int? height Int?
height Int? usedIn Json @default("[]")
dominantColor String? @db.VarChar(7) // Hex color like #FFFFFF createdAt DateTime @default(now())
colors Json? // Full color palette from Cloudinary description String?
aspectRatio Float? // Width/height ratio originalName String? @db.VarChar(255)
exifData Json? // EXIF data for photos updatedAt DateTime @updatedAt
description String? @db.Text // Description (used for alt text and captions) isPhotography Boolean @default(false)
isPhotography Boolean @default(false) // Star for photos experience exifData Json?
photoCaption String?
// Photo-specific fields (migrated from Photo model) photoTitle String? @db.VarChar(255)
photoCaption String? @db.Text // Caption when used as standalone photo photoDescription String?
photoTitle String? @db.VarChar(255) // Title when used as standalone photo photoSlug String? @unique @db.VarChar(255)
photoDescription String? @db.Text // Description when used as standalone photo photoPublishedAt DateTime?
photoSlug String? @unique @db.VarChar(255) // Slug for standalone photo dominantColor String? @db.VarChar(7)
photoPublishedAt DateTime? // Published date for standalone photo colors Json?
aspectRatio Float?
usedIn Json @default("[]") // Track where media is used (legacy) albums AlbumMedia[]
createdAt DateTime @default(now()) usage MediaUsage[]
updatedAt DateTime @updatedAt photos Photo[]
// Relations
usage MediaUsage[]
photos Photo[] // Will be removed after migration
albums AlbumMedia[]
} }
// Media usage tracking table
model MediaUsage { model MediaUsage {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
mediaId Int mediaId Int
contentType String @db.VarChar(50) // 'project', 'post', 'album' contentType String @db.VarChar(50)
contentId Int contentId Int
fieldName String @db.VarChar(100) // 'featuredImage', 'logoUrl', 'gallery', 'content' fieldName String @db.VarChar(100)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
// Relations
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
@@unique([mediaId, contentType, contentId, fieldName]) @@unique([mediaId, contentType, contentId, fieldName])
@@index([mediaId]) @@index([mediaId])
@@index([contentType, contentId]) @@index([contentType, contentId])
} }
// Album-Media relationship table (many-to-many)
model AlbumMedia { model AlbumMedia {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
albumId Int albumId Int
mediaId Int mediaId Int
displayOrder Int @default(0) displayOrder Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
// Relations media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
@@unique([albumId, mediaId]) @@unique([albumId, mediaId])
@@index([albumId]) @@index([albumId])
@@index([mediaId]) @@index([mediaId])
} }
model GeoLocation {
id Int @id @default(autoincrement())
albumId Int
latitude Float
longitude Float
title String @db.VarChar(255)
description String?
markerColor String? @db.VarChar(7)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
@@index([albumId])
}