DB, Collections, Search

This commit is contained in:
Stefan Hardegger
2025-11-21 07:53:37 +01:00
parent c8eb6237c4
commit 8a03edbb88
67 changed files with 17703 additions and 103 deletions

349
prisma/schema.prisma Normal file
View 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
View 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()
})