feat(db): implement complete database schema and models
- Add Prisma schema with PostgreSQL 15 support - Create Users, Batches, Images, Payments, ApiKeys tables - Implement proper foreign key relationships and indexes - Add enum types for status fields (Plan, BatchStatus, ImageStatus, PaymentStatus) - Support for JSON fields (vision_tags, metadata) - UUID primary keys for security - Created/updated timestamps with proper defaults Database Layer Components: - Prisma service with connection management and health checks - Repository pattern for all entities with comprehensive CRUD operations - TypeScript DTOs with class-validator decorations - Swagger API documentation annotations - Helper functions for business logic (quota management, pricing, etc.) Development Support: - Environment variables template - Database seed script with realistic test data - TypeScript configuration optimized for Nest.js - Package.json with all required dependencies Resolves database requirements from issues §78-81 establishing the complete data layer foundation for the AI Bulk Image Renamer SaaS. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
90016254a9
commit
e7e09d5e2c
15 changed files with 3606 additions and 0 deletions
179
packages/api/prisma/schema.prisma
Normal file
179
packages/api/prisma/schema.prisma
Normal file
|
@ -0,0 +1,179 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Enum for user subscription plans
|
||||
enum Plan {
|
||||
BASIC // 50 images per month
|
||||
PRO // 500 images per month
|
||||
MAX // 1000 images per month
|
||||
}
|
||||
|
||||
// Enum for batch processing status
|
||||
enum BatchStatus {
|
||||
PROCESSING
|
||||
DONE
|
||||
ERROR
|
||||
}
|
||||
|
||||
// Enum for individual image processing status
|
||||
enum ImageStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Enum for payment status
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
// Users table - OAuth ready with Google integration
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
googleUid String? @unique @map("google_uid") // Google OAuth UID
|
||||
emailHash String @unique @map("email_hash") // Hashed email for privacy
|
||||
email String @unique // Actual email for communication
|
||||
plan Plan @default(BASIC)
|
||||
quotaRemaining Int @default(50) @map("quota_remaining") // Monthly quota
|
||||
quotaResetDate DateTime @default(now()) @map("quota_reset_date") // When quota resets
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
batches Batch[]
|
||||
payments Payment[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
@@map("users")
|
||||
@@index([emailHash])
|
||||
@@index([googleUid])
|
||||
@@index([plan])
|
||||
}
|
||||
|
||||
// Batches table - Groups of images processed together
|
||||
model Batch {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
status BatchStatus @default(PROCESSING)
|
||||
totalImages Int @default(0) @map("total_images")
|
||||
processedImages Int @default(0) @map("processed_images")
|
||||
failedImages Int @default(0) @map("failed_images")
|
||||
metadata Json? // Additional batch metadata (e.g., processing settings)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
images Image[]
|
||||
|
||||
@@map("batches")
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// Images table - Individual images within batches
|
||||
model Image {
|
||||
id String @id @default(uuid())
|
||||
batchId String @map("batch_id")
|
||||
originalName String @map("original_name")
|
||||
proposedName String? @map("proposed_name") // AI-generated name
|
||||
finalName String? @map("final_name") // User-approved final name
|
||||
visionTags Json? @map("vision_tags") // AI vision analysis results
|
||||
status ImageStatus @default(PENDING)
|
||||
fileSize Int? @map("file_size") // File size in bytes
|
||||
dimensions Json? // Width/height as JSON object
|
||||
mimeType String? @map("mime_type")
|
||||
s3Key String? @map("s3_key") // S3 object key for storage
|
||||
processingError String? @map("processing_error") // Error message if processing failed
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
processedAt DateTime? @map("processed_at")
|
||||
|
||||
// Relations
|
||||
batch Batch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("images")
|
||||
@@index([batchId])
|
||||
@@index([status])
|
||||
@@index([originalName])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// Payments table - Stripe integration for subscription management
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
stripeSessionId String? @unique @map("stripe_session_id") // Stripe Checkout Session ID
|
||||
stripePaymentId String? @unique @map("stripe_payment_id") // Stripe Payment Intent ID
|
||||
plan Plan // The plan being purchased
|
||||
amount Int // Amount in cents
|
||||
currency String @default("usd")
|
||||
status PaymentStatus @default(PENDING)
|
||||
metadata Json? // Additional payment metadata
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("payments")
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([stripeSessionId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// API Keys table - For potential API access
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
keyHash String @unique @map("key_hash") // Hashed API key
|
||||
name String // User-friendly name for the key
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastUsed DateTime? @map("last_used")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
usage ApiKeyUsage[]
|
||||
|
||||
@@map("api_keys")
|
||||
@@index([userId])
|
||||
@@index([keyHash])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
// API Key Usage tracking
|
||||
model ApiKeyUsage {
|
||||
id String @id @default(uuid())
|
||||
apiKeyId String @map("api_key_id")
|
||||
endpoint String // Which API endpoint was called
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("api_key_usage")
|
||||
@@index([apiKeyId])
|
||||
@@index([createdAt])
|
||||
}
|
391
packages/api/prisma/seed.ts
Normal file
391
packages/api/prisma/seed.ts
Normal file
|
@ -0,0 +1,391 @@
|
|||
import { PrismaClient, Plan, BatchStatus, ImageStatus, PaymentStatus } from '@prisma/client';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seed...');
|
||||
|
||||
// Create test users
|
||||
const users = await Promise.all([
|
||||
prisma.user.create({
|
||||
data: {
|
||||
googleUid: 'google_test_user_1',
|
||||
email: 'john.doe@example.com',
|
||||
emailHash: crypto.createHash('sha256').update('john.doe@example.com').digest('hex'),
|
||||
plan: Plan.BASIC,
|
||||
quotaRemaining: 50,
|
||||
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
},
|
||||
}),
|
||||
prisma.user.create({
|
||||
data: {
|
||||
googleUid: 'google_test_user_2',
|
||||
email: 'jane.smith@example.com',
|
||||
emailHash: crypto.createHash('sha256').update('jane.smith@example.com').digest('hex'),
|
||||
plan: Plan.PRO,
|
||||
quotaRemaining: 450,
|
||||
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}),
|
||||
prisma.user.create({
|
||||
data: {
|
||||
googleUid: 'google_test_user_3',
|
||||
email: 'bob.wilson@example.com',
|
||||
emailHash: crypto.createHash('sha256').update('bob.wilson@example.com').digest('hex'),
|
||||
plan: Plan.MAX,
|
||||
quotaRemaining: 900,
|
||||
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${users.length} test users`);
|
||||
|
||||
// Create test batches
|
||||
const batches = [];
|
||||
|
||||
// Completed batch for first user
|
||||
const completedBatch = await prisma.batch.create({
|
||||
data: {
|
||||
userId: users[0].id,
|
||||
status: BatchStatus.DONE,
|
||||
totalImages: 5,
|
||||
processedImages: 4,
|
||||
failedImages: 1,
|
||||
completedAt: new Date(),
|
||||
metadata: {
|
||||
processingOptions: {
|
||||
includeColors: true,
|
||||
includeTags: true,
|
||||
aiModel: 'gpt-4-vision',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
batches.push(completedBatch);
|
||||
|
||||
// Processing batch for second user
|
||||
const processingBatch = await prisma.batch.create({
|
||||
data: {
|
||||
userId: users[1].id,
|
||||
status: BatchStatus.PROCESSING,
|
||||
totalImages: 10,
|
||||
processedImages: 6,
|
||||
failedImages: 1,
|
||||
metadata: {
|
||||
processingOptions: {
|
||||
includeColors: true,
|
||||
includeTags: true,
|
||||
includeScene: true,
|
||||
aiModel: 'gpt-4-vision',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
batches.push(processingBatch);
|
||||
|
||||
// Error batch for third user
|
||||
const errorBatch = await prisma.batch.create({
|
||||
data: {
|
||||
userId: users[2].id,
|
||||
status: BatchStatus.ERROR,
|
||||
totalImages: 3,
|
||||
processedImages: 0,
|
||||
failedImages: 3,
|
||||
completedAt: new Date(),
|
||||
metadata: {
|
||||
error: 'Invalid image format detected',
|
||||
},
|
||||
},
|
||||
});
|
||||
batches.push(errorBatch);
|
||||
|
||||
console.log(`✅ Created ${batches.length} test batches`);
|
||||
|
||||
// Create test images for completed batch
|
||||
const completedBatchImages = await Promise.all([
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'IMG_20240101_123456.jpg',
|
||||
proposedName: 'modern-kitchen-with-stainless-steel-appliances.jpg',
|
||||
finalName: 'kitchen-renovation-final.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 2048576,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 1920, height: 1080, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['kitchen', 'refrigerator', 'countertop', 'cabinets'],
|
||||
colors: ['white', 'stainless steel', 'black'],
|
||||
scene: 'modern kitchen interior',
|
||||
description: 'A modern kitchen with stainless steel appliances and white cabinets',
|
||||
confidence: 0.95,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 2.5,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/IMG_20240101_123456.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'DSC_0001.jpg',
|
||||
proposedName: 'cozy-living-room-with-fireplace.jpg',
|
||||
finalName: 'living-room-cozy-fireplace.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 3145728,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 2560, height: 1440, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['fireplace', 'sofa', 'coffee table', 'lamp'],
|
||||
colors: ['brown', 'cream', 'orange'],
|
||||
scene: 'cozy living room',
|
||||
description: 'A cozy living room with a warm fireplace and comfortable seating',
|
||||
confidence: 0.92,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 3.1,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/DSC_0001.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'photo_2024_01_01.png',
|
||||
proposedName: 'elegant-bedroom-with-natural-light.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 1572864,
|
||||
mimeType: 'image/png',
|
||||
dimensions: { width: 1600, height: 900, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['bed', 'window', 'curtains', 'nightstand'],
|
||||
colors: ['white', 'beige', 'natural'],
|
||||
scene: 'elegant bedroom',
|
||||
description: 'An elegant bedroom with natural light streaming through large windows',
|
||||
confidence: 0.88,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 2.8,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/photo_2024_01_01.png',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'bathroom_pic.jpg',
|
||||
proposedName: 'luxury-bathroom-with-marble-tiles.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 2621440,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 1920, height: 1080, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['bathroom', 'bathtub', 'marble', 'mirror'],
|
||||
colors: ['white', 'marble', 'chrome'],
|
||||
scene: 'luxury bathroom',
|
||||
description: 'A luxury bathroom featuring marble tiles and modern fixtures',
|
||||
confidence: 0.94,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 3.3,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/bathroom_pic.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'corrupt_image.jpg',
|
||||
status: ImageStatus.FAILED,
|
||||
fileSize: 0,
|
||||
mimeType: 'image/jpeg',
|
||||
processingError: 'Image file is corrupted and cannot be processed',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create test images for processing batch
|
||||
const processingBatchImages = await Promise.all([
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'garden_view.jpg',
|
||||
proposedName: 'beautiful-garden-with-colorful-flowers.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 4194304,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 3840, height: 2160, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['garden', 'flowers', 'grass', 'trees'],
|
||||
colors: ['green', 'red', 'yellow', 'purple'],
|
||||
scene: 'beautiful garden',
|
||||
description: 'A beautiful garden with colorful flowers and lush greenery',
|
||||
confidence: 0.97,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 4.2,
|
||||
},
|
||||
s3Key: 'uploads/user2/batch2/garden_view.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'office_space.png',
|
||||
proposedName: 'modern-office-workspace-with-computer.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 2097152,
|
||||
mimeType: 'image/png',
|
||||
dimensions: { width: 2560, height: 1600, aspectRatio: '8:5' },
|
||||
visionTags: {
|
||||
objects: ['desk', 'computer', 'chair', 'monitor'],
|
||||
colors: ['white', 'black', 'blue'],
|
||||
scene: 'modern office',
|
||||
description: 'A modern office workspace with computer and ergonomic furniture',
|
||||
confidence: 0.91,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 3.7,
|
||||
},
|
||||
s3Key: 'uploads/user2/batch2/office_space.png',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'current_processing.jpg',
|
||||
status: ImageStatus.PROCESSING,
|
||||
fileSize: 1835008,
|
||||
mimeType: 'image/jpeg',
|
||||
s3Key: 'uploads/user2/batch2/current_processing.jpg',
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'pending_image_1.jpg',
|
||||
status: ImageStatus.PENDING,
|
||||
fileSize: 2359296,
|
||||
mimeType: 'image/jpeg',
|
||||
s3Key: 'uploads/user2/batch2/pending_image_1.jpg',
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'pending_image_2.png',
|
||||
status: ImageStatus.PENDING,
|
||||
fileSize: 1048576,
|
||||
mimeType: 'image/png',
|
||||
s3Key: 'uploads/user2/batch2/pending_image_2.png',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${completedBatchImages.length + processingBatchImages.length} test images`);
|
||||
|
||||
// Create test payments
|
||||
const payments = await Promise.all([
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: users[1].id, // Jane Smith upgrading to PRO
|
||||
stripeSessionId: 'cs_test_stripe_session_123',
|
||||
stripePaymentId: 'pi_test_stripe_payment_123',
|
||||
plan: Plan.PRO,
|
||||
amount: 2999, // $29.99
|
||||
currency: 'usd',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
paidAt: new Date(),
|
||||
metadata: {
|
||||
stripeCustomerId: 'cus_test_customer_123',
|
||||
previousPlan: Plan.BASIC,
|
||||
upgradeReason: 'Need more quota for business use',
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: users[2].id, // Bob Wilson upgrading to MAX
|
||||
stripeSessionId: 'cs_test_stripe_session_456',
|
||||
stripePaymentId: 'pi_test_stripe_payment_456',
|
||||
plan: Plan.MAX,
|
||||
amount: 4999, // $49.99
|
||||
currency: 'usd',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
paidAt: new Date(),
|
||||
metadata: {
|
||||
stripeCustomerId: 'cus_test_customer_456',
|
||||
previousPlan: Plan.PRO,
|
||||
upgradeReason: 'Agency needs maximum quota',
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: users[0].id, // John Doe failed payment
|
||||
stripeSessionId: 'cs_test_stripe_session_789',
|
||||
plan: Plan.PRO,
|
||||
amount: 2999,
|
||||
currency: 'usd',
|
||||
status: PaymentStatus.FAILED,
|
||||
metadata: {
|
||||
error: 'Insufficient funds',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${payments.length} test payments`);
|
||||
|
||||
// Create test API keys
|
||||
const apiKeys = await Promise.all([
|
||||
prisma.apiKey.create({
|
||||
data: {
|
||||
userId: users[1].id,
|
||||
keyHash: crypto.createHash('sha256').update('test_api_key_pro_user').digest('hex'),
|
||||
name: 'Production API Key',
|
||||
isActive: true,
|
||||
lastUsed: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.apiKey.create({
|
||||
data: {
|
||||
userId: users[2].id,
|
||||
keyHash: crypto.createHash('sha256').update('test_api_key_max_user').digest('hex'),
|
||||
name: 'Development API Key',
|
||||
isActive: true,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${apiKeys.length} test API keys`);
|
||||
|
||||
console.log('🎉 Database seed completed successfully!');
|
||||
|
||||
// Print summary
|
||||
console.log('\n📊 Seed Summary:');
|
||||
console.log(` Users: ${users.length}`);
|
||||
console.log(` Batches: ${batches.length}`);
|
||||
console.log(` Images: ${completedBatchImages.length + processingBatchImages.length}`);
|
||||
console.log(` Payments: ${payments.length}`);
|
||||
console.log(` API Keys: ${apiKeys.length}`);
|
||||
|
||||
console.log('\n👥 Test Users:');
|
||||
users.forEach(user => {
|
||||
console.log(` ${user.email} (${user.plan}) - Quota: ${user.quotaRemaining}`);
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue