DB, Collections, Search
This commit is contained in:
349
prisma/schema.prisma
Normal file
349
prisma/schema.prisma
Normal file
@@ -0,0 +1,349 @@
|
||||
// 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")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ENUMS
|
||||
// ============================================================================
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
enum CharacterDisplay {
|
||||
SIMPLIFIED
|
||||
TRADITIONAL
|
||||
BOTH
|
||||
}
|
||||
|
||||
enum Difficulty {
|
||||
EASY
|
||||
MEDIUM
|
||||
HARD
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LANGUAGE & HANZI MODELS
|
||||
// ============================================================================
|
||||
|
||||
model Language {
|
||||
id String @id @default(cuid())
|
||||
code String @unique // ISO 639-1 code (e.g., "en", "zh", "es")
|
||||
name String // English name (e.g., "English", "Chinese")
|
||||
nativeName String // Native name (e.g., "English", "中文")
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
meanings HanziMeaning[]
|
||||
userPreferences UserPreference[]
|
||||
|
||||
@@map("languages")
|
||||
}
|
||||
|
||||
model Hanzi {
|
||||
id String @id @default(cuid())
|
||||
simplified String @unique // The simplified Chinese character
|
||||
radical String? // Radical/base component
|
||||
frequency Int? // Frequency ranking (lower = more common)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
forms HanziForm[]
|
||||
hskLevels HanziHSKLevel[]
|
||||
partsOfSpeech HanziPOS[]
|
||||
userProgress UserHanziProgress[]
|
||||
collectionItems CollectionItem[]
|
||||
sessionReviews SessionReview[]
|
||||
|
||||
@@index([simplified])
|
||||
@@map("hanzi")
|
||||
}
|
||||
|
||||
model HanziForm {
|
||||
id String @id @default(cuid())
|
||||
hanziId String
|
||||
traditional String // Traditional variant
|
||||
isDefault Boolean @default(false) // Primary traditional form
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
transcriptions HanziTranscription[]
|
||||
meanings HanziMeaning[]
|
||||
classifiers HanziClassifier[]
|
||||
|
||||
@@index([hanziId])
|
||||
@@index([traditional])
|
||||
@@map("hanzi_forms")
|
||||
}
|
||||
|
||||
model HanziTranscription {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
type String // "pinyin", "numeric", "wadegiles", etc.
|
||||
value String // The actual transcription
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form HanziForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId])
|
||||
@@index([type, value])
|
||||
@@map("hanzi_transcriptions")
|
||||
}
|
||||
|
||||
model HanziMeaning {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
languageId String
|
||||
meaning String @db.Text // Translation/definition
|
||||
orderIndex Int @default(0) // Order of meanings
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form HanziForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
language Language @relation(fields: [languageId], references: [id])
|
||||
|
||||
@@index([formId])
|
||||
@@index([languageId])
|
||||
@@map("hanzi_meanings")
|
||||
}
|
||||
|
||||
model HanziHSKLevel {
|
||||
id String @id @default(cuid())
|
||||
hanziId String
|
||||
level String // "new-1", "old-3", etc.
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([hanziId])
|
||||
@@index([level])
|
||||
@@map("hanzi_hsk_levels")
|
||||
}
|
||||
|
||||
model HanziPOS {
|
||||
id String @id @default(cuid())
|
||||
hanziId String
|
||||
pos String // "n", "v", "adj", "adv", etc.
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([hanziId])
|
||||
@@map("hanzi_pos")
|
||||
}
|
||||
|
||||
model HanziClassifier {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
classifier String // Measure word
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form HanziForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId])
|
||||
@@map("hanzi_classifiers")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER & AUTH MODELS
|
||||
// ============================================================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
password String // Hashed with bcrypt
|
||||
name String?
|
||||
role UserRole @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
collections Collection[]
|
||||
hanziProgress UserHanziProgress[]
|
||||
preference UserPreference?
|
||||
learningSessions LearningSession[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
preferredLanguageId String
|
||||
characterDisplay CharacterDisplay @default(SIMPLIFIED)
|
||||
transcriptionType String @default("pinyin") // "pinyin", "numeric", etc.
|
||||
cardsPerSession Int @default(20)
|
||||
dailyGoal Int @default(50) // Cards per day
|
||||
removalThreshold Int @default(10) // Consecutive correct before suggesting removal
|
||||
allowManualDifficulty Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
preferredLanguage Language @relation(fields: [preferredLanguageId], references: [id])
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
// NextAuth.js models
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LEARNING MODELS
|
||||
// ============================================================================
|
||||
|
||||
model Collection {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String? @db.Text
|
||||
isGlobal Boolean @default(false) // Global collections (HSK levels) vs user collections
|
||||
createdBy String?
|
||||
isPublic Boolean @default(false) // User collections can be public
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
items CollectionItem[]
|
||||
learningSessions LearningSession[]
|
||||
|
||||
@@index([createdBy])
|
||||
@@index([isGlobal])
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
model CollectionItem {
|
||||
id String @id @default(cuid())
|
||||
collectionId String
|
||||
hanziId String
|
||||
orderIndex Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([collectionId, hanziId])
|
||||
@@index([collectionId])
|
||||
@@index([hanziId])
|
||||
@@map("collection_items")
|
||||
}
|
||||
|
||||
model UserHanziProgress {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
hanziId String
|
||||
correctCount Int @default(0)
|
||||
incorrectCount Int @default(0)
|
||||
consecutiveCorrect Int @default(0)
|
||||
easeFactor Float @default(2.5) // SM-2 algorithm
|
||||
interval Int @default(1) // Days between reviews
|
||||
nextReviewDate DateTime @default(now())
|
||||
manualDifficulty Difficulty? // Optional manual override
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, hanziId])
|
||||
@@index([userId, nextReviewDate])
|
||||
@@index([hanziId])
|
||||
@@map("user_hanzi_progress")
|
||||
}
|
||||
|
||||
model LearningSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
collectionId String?
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
cardsReviewed Int @default(0)
|
||||
correctAnswers Int @default(0)
|
||||
incorrectAnswers Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
collection Collection? @relation(fields: [collectionId], references: [id], onDelete: SetNull)
|
||||
reviews SessionReview[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([startedAt])
|
||||
@@map("learning_sessions")
|
||||
}
|
||||
|
||||
model SessionReview {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
hanziId String
|
||||
isCorrect Boolean
|
||||
responseTime Int? // Milliseconds
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
session LearningSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([hanziId])
|
||||
@@map("session_reviews")
|
||||
}
|
||||
87
prisma/seed.ts
Normal file
87
prisma/seed.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Starting database seeding...')
|
||||
|
||||
// Create English language
|
||||
const english = await prisma.language.upsert({
|
||||
where: { code: 'en' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created language:', english.name)
|
||||
|
||||
// Create admin user
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10)
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@memohanzi.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@memohanzi.local',
|
||||
password: hashedPassword,
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
isActive: true,
|
||||
preference: {
|
||||
create: {
|
||||
preferredLanguageId: english.id,
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
transcriptionType: 'pinyin',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created admin user:', adminUser.email)
|
||||
|
||||
// Create test user
|
||||
const testPassword = await bcrypt.hash('test123', 10)
|
||||
const testUser = await prisma.user.upsert({
|
||||
where: { email: 'user@memohanzi.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'user@memohanzi.local',
|
||||
password: testPassword,
|
||||
name: 'Test User',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
preference: {
|
||||
create: {
|
||||
preferredLanguageId: english.id,
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
transcriptionType: 'pinyin',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created test user:', testUser.email)
|
||||
|
||||
console.log('Database seeding completed!')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Error during seeding:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
Reference in New Issue
Block a user