- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Hogwarts runs on a multi-tenant PostgreSQL database. All schools share the schema; data is isolated by schoolId on every tenant-scoped row. ORM is Prisma 6.19+ with multi-file schema organisation.
Principles
- Row-level isolation — every tenant model has
schoolId. - Shared schema — one schema, many schools.
- Subdomain-based identity — schools are unique by subdomain (e.g.
kingfahad.databayt.org); custom domains via CNAME. - End-to-end type safety — Prisma → Zod → TypeScript with no manual handoffs.
- Multi-file schema — 73 logical
.prismafiles inprisma/models/for maintainability. - Single-language storage — content stored in one language with a
langfield; translation on-demand via Google Translate cached inTranslation.
Layout
302 models across 73 schema files, grouped by domain. Catalog models intentionally omit schoolId — they're platform-wide reference content shared across schools via the bridge pattern.
| Domain | Models | Files |
|---|---|---|
| Core (school, year, term, period, subscription) | 12 | school.prisma |
| Auth and users | 7 | auth.prisma |
| People (students, teachers, parents, staff) | 32 | students.prisma, staff.prisma, staff-member.prisma, profile.prisma |
| Academic structure | 13 | academic.prisma, subjects.prisma, classrooms.prisma, enrollment.prisma |
| Catalog (global) | 22+ | catalog.prisma, bridge.prisma, chapter.prisma, lesson.prisma, etc. |
| Attendance | 33+ | attendance.prisma, attendance-enhanced.prisma, geo-attendance.prisma |
| Exams and assessment | 40+ | exam.prisma, school-exam.prisma, school-qbank.prisma, quiz.prisma, quiz-game.prisma, quick-assessments.prisma, etc. |
| Finance | 36 | finance-core.prisma, finance-fees.prisma, finance-invoices.prisma, finance-payroll.prisma, finance-banking.prisma, finance-budgets.prisma, finance-reports.prisma, subscription.prisma |
| Messaging | 11 | messages.prisma |
| Notifications | 7 | notifications.prisma |
| 5 | whatsapp.prisma | |
| Announcements | 5 | announcement.prisma |
| Admission | 12 | admission.prisma, visit.prisma, membership.prisma, promotion.prisma |
| Lumos / LMS | 8 | stream.prisma |
| Timetable | 9 | timetable.prisma, schedule.prisma |
| Transportation | 7 | transportation.prisma |
| Library | 4 | book.prisma, school-book.prisma, textbook.prisma |
| Files / documents | 15 | files.prisma, file-record.prisma, document.prisma, document-processing.prisma, image.prisma, video.prisma |
| Audit / security / misc | 10 | audit.prisma, legal.prisma, webhooks.prisma, task.prisma, translation.prisma, purge-tokens.prisma |
Multi-tenant isolation
School model
model School {
id String @id @default(cuid())
name String
domain String @unique // e.g. "kingfahad"
preferredLanguage String @default("ar")
logoUrl String?
address String?
phoneNumber String?
email String?
timezone String @default("Africa/Khartoum")
schoolType String? // private, public, international, technical, special
schoolLevel String? // primary, secondary, both
curriculum String? // @map column: timetable-structure slug ("us-standard"); curriculum CODE (US/SD/GB/IB-DP) is inferred at runtime, not stored
planType String @default("basic")
maxStudents Int @default(100)
maxTeachers Int @default(10)
isActive Boolean @default(true)
}Query rules
// correct — always include schoolId
await db.student.findMany({ where: { schoolId } })
// wrong — leaks data across tenants
await db.student.findMany()Every tenant-scoped Prisma model has:
schoolId Stringfield.school School @relation(...)withonDelete: Cascade.@@unique([schoolId, ...])so cross-tenant collisions are impossible.@@index([schoolId])for query performance.
Models without schoolId
About 44 models intentionally omit schoolId:
- Auth tokens (4):
Account,VerificationToken,PasswordResetToken,TwoFactorToken— email-scoped, ephemeral. - Catalog globals (22+):
Subject,Chapter,Lesson,Book,Textbook,Curriculum, etc. — platform-wide reference content; bridge models scope per-school selections. (CurriculumStandardis school-scoped, not a catalog global.) - Subscription tiers (1): platform-wide pricing.
Dual LMS architecture
Two parallel content systems:
| System | Scope | Models | Source |
|---|---|---|---|
| Lumos | School-scoped (schoolId) | StreamCourse, StreamCategory, StreamEnrollment, StreamLesson, StreamCertificate | School-created courses |
| Catalog | Platform-wide (no schoolId) | Subject, Chapter, Lesson, Material, Question | curated |
The bridge.prisma models (SubjectSelection, BookSelection, ContentOverride, InstructorPreference) connect catalog content to school-specific academic structure. Schools select which subjects they use and override content without touching shared originals.
User roles
8 roles in prisma/models/auth.prisma:UserRole. See Multi-tenancy for the RBAC matrix.
| Role | School scope | Use |
|---|---|---|
DEVELOPER | All schools | Platform admin, cross-tenant ops |
ADMIN | Single | School management, user oversight |
TEACHER | Single | Class management, grading, attendance |
STUDENT | Single | Own grades, attendance, assignments |
GUARDIAN | Single | Linked-student data, payments, notifications |
ACCOUNTANT | Single | Billing, financial reporting (no academic) |
STAFF | Single | Operational support |
USER | None | Default post-signup, pre-onboarding |
Subscription and billing
model SubscriptionTier {
id String @id @default(cuid())
name String // basic, premium, enterprise
monthlyPrice Int // cents
annualPrice Int // cents
maxStudents Int
features String[]
isActive Boolean @default(true)
subscriptions Subscription[]
discounts Discount[]
}
model Discount {
id String @id @default(cuid())
schoolId String
tierId String
code String @unique
type String // percentage, fixed
value Int
validFrom DateTime
validUntil DateTime
maxUses Int?
currentUses Int @default(0)
school School @relation(fields: [schoolId], references: [id])
subscriptionTier SubscriptionTier @relation(fields: [tierId], references: [id])
}Plus BillingPaymentMethod, BillingHistory, UsageMetrics, CreditNote, BillingPreferences, AppliedDiscount for the full billing lifecycle.
Legal and compliance
model LegalConsent {
id String @id @default(cuid())
schoolId String
userId String
documentType String // terms, privacy, data-processing
documentVersion String
consentType String // explicit, implicit, parental
ipAddress String?
userAgent String?
consentedAt DateTime @default(now())
revokedAt DateTime?
@@unique([schoolId, userId, documentType, documentVersion])
}
model LegalDocument {
id String @id @default(cuid())
schoolId String
type String // terms, privacy, data-processing
version String
content String @db.Text
effectiveFrom DateTime
effectiveUntil DateTime?
isActive Boolean @default(true)
requiresExplicit Boolean @default(true)
}
model ComplianceLog {
id String @id @default(cuid())
schoolId String
eventType String
eventData Json
userId String?
timestamp DateTime @default(now())
}Single-language storage
Content stored in one language with a lang field. Translation on-demand via Google Translate API, cached in Translation.
model Announcement {
title String?
body String? @db.Text
lang String @default("ar")
}
model Translation {
id String @id @default(cuid())
schoolId String
sourceText String @db.Text
sourceLanguage String // "ar" or "en"
targetLanguage String // "ar" or "en"
translatedText String @db.Text
provider String @default("google")
hitCount Int @default(0)
lastAccessedAt DateTime @default(now())
@@unique([schoolId, sourceText, sourceLanguage, targetLanguage])
@@index([schoolId, sourceLanguage, targetLanguage])
@@index([lastAccessedAt])
}import { getText } from "@/components/translation/display"
// Stored in Arabic, viewing in English → translates and caches
const title = await getText("مرحبا", "ar", "en", schoolId)
// Same language → returns directly, no API call
const title = await getText("مرحبا", "ar", "ar", schoolId)Models with lang
Announcement, AnnouncementTemplate, NotificationTemplate, Subject, Class, YearLevel, Department, AcademicLevel, AcademicGrade, AcademicStream, AttendanceBadge, AttendanceCompetition, Chapter, Lesson, Book, Textbook, Material, CurriculumStandard, GradingScheme, QuickAssessment, Video, StreamCourse, StreamCategory. Default lang = "ar" for school-content; lang = "en" for Lumos/LMS.
See Internationalization and Translation.
Performance
Recommended indexes:
CREATE INDEX idx_students_school_id ON students(school_id);
CREATE INDEX idx_teachers_school_id ON teachers(school_id);
CREATE INDEX idx_classes_school_id ON classes(school_id);
CREATE INDEX idx_attendance_school_id ON attendance(school_id);Guidelines:
- Always filter by
schoolIdfirst inWHEREclauses. - Use composite indexes for frequently queried field combinations.
- Connection pooling via Neon serverless.
- Read replicas for reporting and analytics.
Deployment
Environment
DATABASE_URL="postgresql://username:password@host:port/database"
DEFAULT_SCHOOL_DOMAIN="demo"
ALLOW_SCHOOL_SIGNUP="true"
BASIC_MAX_STUDENTS=100
PREMIUM_MAX_STUDENTS=500
ENTERPRISE_MAX_STUDENTS=2000Migrations
pnpm prisma generate # Regenerate client
pnpm prisma migrate dev --name <name> # LOCAL only — creates + applies a migration
pnpm db:seed:single <name> # Seed one module — never `pnpm db:seed`
prisma migrate deployis the wrong tool against production, and this page used to recommend it. Verified 2026-08-18: the production database has no_prisma_migrationstable — it isdb push-managed — somigrate deploywould try to apply all 38 repo migrations from scratch onto a schema that already exists. Do not run it against prod.
To change the production schema, take the DDL from a diff and apply only what you meant:
# The FOLDER, not prisma/schema.prisma. prisma.config.ts loads models from prisma/, so
# pointing at the lone datasource file yields an EMPTY datamodel — and the diff then claims
# 719 dropped foreign keys and 328 dropped tables. That is an artifact of the argument, not
# drift. Real drift is a couple of dozen lines.
pnpm prisma migrate diff --from-url "$PROD_DATABASE_URL" --to-schema-datamodel prisma --scriptRead what it prints, apply just the statements you intend, then re-run the diff to confirm it comes back empty. Adding a nullable column with no default is a catalog-only change in Postgres — instant, no table rewrite, existing rows untouched.
Order matters: the database changes before the code that expects it. Vercel's build runs
only prisma generate (see postinstall), never migrate deploy, so a schema field that
ships ahead of its column makes every query on that model fail in production.
Destructive ops (db execute, db push --accept-data-loss, migrate reset, DROP TABLE, TRUNCATE) are blocked by hooks. Use Neon's Branch-Before-Touch protocol for risky DB ops.
Demo school
Single demo tenant (demo.databayt.org) with full K-12 Sudanese simulation. See Safe seeds.
| Entity | Count | Notes |
|---|---|---|
| Users | 3,105 | All roles, @databayt.org emails |
| Teachers | 100 | Arabic names, 6 departments |
| Students | 970 | KG1–Grade 12 |
| Guardians | ~1,940 | 2 per student |
| Departments | 6 | Arabic primary + lang field |
| Subjects | 19 | Arabic primary + lang field |
| Year levels | 14 | KG1–12 |
| Classrooms | 28 | Labs, rooms, special facilities |
| Classes | 187 | Subject-level combinations |
See also
- Multi-tenancy — request flow, RBAC, isolation guarantees
- Internationalization — i18n contract
- Translation — dictionary coverage
- Catalog — global content + bridge pattern
- Seeds — full seed catalogue
- Safe seeds — protected dev accounts