ein freies Lehrmittel auf der Basis von eduskript

Data Models

The database schema lives in prisma/schema.prisma. This is a tour of the main models, not exhaustive — use prisma studio (pnpm db:studio) to browse everything live.


Core content hierarchy

User
 └── Collection (course)
      └── CollectionSkript (junction, with order)
           └── Skript (module)
                └── Page (lesson)
                     └── File (attachment, scoped to Skript)

Collections and Skripts have many-to-many — a skript can belong to multiple collections.


User

model User {
  id            String    @id @default(cuid())
  email         String?   @unique
  name          String?              // Profile name
  bio           String?              // Profile bio
  title         String?              // Profile title (e.g. "Math teacher")
  pageSlug      String?   @unique    // URL: eduskript.org/[pageSlug]
  pageName      String?              // Display name for public page
  pageDescription String?            // Public page description
  isAdmin       Boolean   @default(false)
  accountType   String    @default("teacher")  // "teacher" | "student"
  studentPseudonym String?           // For student accounts
  oauthProvider    String?           // For student accounts (no email stored)
  oauthProviderId  String?
}

Page vs Profile fields (important distinction):

  • pageSlug, pageName, pageDescription → everything about the user's public page (what students see)
  • name, bio, title → everything about the user's profile (shown to collaborators)

Students use oauthProvider + oauthProviderId instead of email for privacy.


Content models

model Collection {
  id          String  @id @default(cuid())
  title       String
  slug        String
  description String?
}

model Skript {
  id              String  @id @default(cuid())
  title           String
  slug            String
  description     String?
  isPublished     Boolean @default(false)
  forkedFromId    String?           // Provenance chain for forks
}

model Page {
  id          String  @id @default(cuid())
  title       String
  slug        String
  content     String  @db.Text
  order       Int     @default(0)
  isPublished Boolean @default(false)
  isUnlisted  Boolean @default(false)
  pageType    String  @default("page")  // "page" | "exam" | "frontpage"
  skriptId    String
}

Junction tables

model CollectionSkript {
  collectionId String
  skriptId     String
  order        Int @default(0)

  @@id([collectionId, skriptId])
}

Permission tables

One author table per content type:

model CollectionAuthor {
  collectionId String
  userId       String
  permission   String  // "author" | "viewer"
  role         String  @default("author")  // "author" | "contributor"

  @@id([collectionId, userId])
}

model SkriptAuthor { ... same shape }
model PageAuthor   { ... same shape }

permission controls access level (edit vs read). role controls copyright ownership semantics (co-author vs contributor — see the Content License chapter in the user manual).


File storage

model File {
  id          String  @id @default(cuid())
  name        String
  hash        String?           // SHA256 for deduplication
  contentType String?
  size        BigInt?
  skriptId    String            // Parent skript (files are per-skript)
  parentId    String?           // For nested directories
  createdBy   String
  isDirectory Boolean @default(false)

  @@unique([parentId, name, skriptId])
}

Content-addressed by hash. See the File Storage chapter for the S3 storage layer.


Video (Mux-hosted)

model Video {
  id           String  @id @default(cuid())
  skriptId     String
  filename     String
  playbackId   String            // Mux playback ID
  metadata     Json              // { duration, posterUrl, aspectRatio, ... }
  status       String            // "processing" | "ready" | "errored"
}

Videos are separate from the File table — they use Mux for streaming, not S3.


Plugins

model Plugin {
  id               String  @id @default(cuid())
  slug             String
  ownerPageSlug    String            // For URL: /ownerSlug/pluginSlug
  name             String
  description      String?
  entryHtml        String  @db.Text
  manifest         Json              // { defaultHeight, configSchema, ... }
  forkedFromId     String?
  isPublic         Boolean @default(true)

  @@unique([ownerPageSlug, slug])
}

Plugin HTML is stored inline. See the Plugins chapter in extending/ for how they're sandboxed and embedded.


UserData (student work, multi-adapter)

model UserData {
  id       String @id @default(cuid())
  userId   String
  adapter  String  // 'code' | 'annotations' | 'settings' | 'snaps' | 'preferences' | 'plugin'
  itemId   String  // pageId, or 'global' for per-user state
  data     Json    // Flexible payload per adapter
  updatedAt DateTime @updatedAt

  @@unique([userId, adapter, itemId])
}

One generic table for every kind of persisted user state. The adapter field routes to per-type schemas enforced in src/lib/userdata/adapters.ts.


FrontPage (custom landing pages)

model FrontPage {
  id          String  @id @default(cuid())
  userId      String?              // User's landing page
  collectionId String?             // Collection's landing page
  skriptId    String?              // Skript's landing page
  organizationId String?           // Organization's landing page
  content     String  @db.Text
}

A user, collection, skript, or organization can have at most one FrontPage — a custom markdown document used as the "front door" before students dive into pages.

Also FrontPageVersion for version history, same shape.


PageLayout (public page organization)

model PageLayout {
  id     String  @id @default(cuid())
  userId String  @unique
}

model PageLayoutItem {
  layoutId   String
  type       String           // "collection" | "skript" | "text"
  contentId  String?          // ID of the collection or skript
  order      Int
}

Structures what appears on a user's public landing page, and in what order.


Organizations

model Organization {
  id          String  @id @default(cuid())
  name        String
  slug        String  @unique      // /org/[slug]
  billingPlan String  @default("free")

  members     OrganizationMember[]
}

model OrganizationMember {
  organizationId String
  userId         String
  role           String  @default("member")  // "owner" | "admin" | "member"

  @@unique([organizationId, userId])
}

All users belong to the default Eduskript org. Platform admins (isAdmin=true) transcend org boundaries.


Classes

model Class {
  id             String  @id @default(cuid())
  name           String
  teacherId      String
  inviteCode     String  @unique
  allowAnonymous Boolean @default(false)
  archived       Boolean @default(false)
}

model ClassMembership {
  classId        String
  userId         String
  identityConsent Boolean @default(false)
  joinedAt       DateTime @default(now())

  @@unique([classId, userId])
}

model PreAuthorizedStudent {
  classId    String
  pseudonym  String
  claimedBy  String?           // User ID if matched
}

Exams and submissions

model ExamState {
  pageId   String
  classId  String
  state    String           // "closed" | "lobby" | "open"
  openedAt DateTime?
  closedAt DateTime?

  @@id([pageId, classId])
}

model StudentSubmission {
  id         String   @id @default(cuid())
  userId     String
  pageId     String
  classId    String?
  content    Json               // Snapshot of all editor state, quiz answers, etc.
  score      Float?             // Auto-graded score
  manualScore Float?            // Teacher-assigned override
  feedback   String?            // Teacher feedback
  submittedAt DateTime
}

Exam state is per-page-per-class. Submissions are per-student-per-page.


Import/export jobs

model ImportJob {
  id        String   @id @default(cuid())
  userId    String
  status    String              // "queued" | "running" | "complete" | "errored"
  progress  Int      @default(0)
  payload   Json
  result    Json?
}

For async operations like bulk imports, exports, AI Edit generation, seed operations. The UI polls /api/import-jobs/:id for progress.


Common queries

Get user's collections (as author):

await prisma.collection.findMany({
  where: { authors: { some: { userId, permission: 'author' } } }
})

Get skripts in a collection, ordered:

await prisma.collectionSkript.findMany({
  where: { collectionId },
  orderBy: { order: 'asc' },
  include: { skript: true }
})

Get pages in a skript, ordered:

await prisma.page.findMany({
  where: { skriptId },
  orderBy: { order: 'asc' }
})

Get user's user-data for a page:

await prisma.userData.findUnique({
  where: {
    userId_adapter_itemId: {
      userId, adapter: 'code', itemId: pageId
    }
  }
})