- مقدمة
- العرض
- MVP
- خارطة الطريق
- متطلبات المنتج
- ابدأ الآن
- البيئة المحلية
- البنية المعمارية
- الهيكل
- الأنماط
- الحزمة التقنية
- قاعدة البيانات
- تعدّد المستأجرين
- الفهرس (Catalog)
- العمل دون اتصال
- المصادقة
- التهيئة (Onboarding)
- تدفق التكامل (Integration Flow)
- التهيئة (Provision)
- القبول
- الحضور
- الامتثال التنظيمي
- الملف الشخصي
- الاجتماعات
- إدارة الرسوم
- الفاتورة
- التدويل
- الترجمة
- دليل الترجمة
- أمان قاعدة البيانات
- المساهمة
- هَوْغوارتس
- عرض حي
- الإلهام
تعمل Hogwarts على قاعدة بيانات PostgreSQL متعدّدة المستأجرين. كل المدارس تشترك في نفس الـschema، والبيانات تُعزَل عبر schoolId على كل صف خاص بالمستأجر. الـORM هو Prisma 6.19+ مع تنظيم schema متعدّد الملفات.
المبادئ
- عزل على مستوى الصف — كل نموذج مستأجر يحمل
schoolId. - schema مشترك — schema واحد، مدارس متعدّدة.
- هوية بالنطاق الفرعي — المدارس فريدة بالنطاق الفرعي (مثل
kingfahad.databayt.org)، نطاقات مخصّصة عبر CNAME. - أمان نوع end-to-end — Prisma → Zod → TypeScript بلا تسليمات يدوية.
- schema متعدّد الملفات — 73 ملف
.prismaمنطقياً فيprisma/models/للصيانة. - تخزين بلغة واحدة — المحتوى يُخزَّن بلغة واحدة مع حقل
lang، والترجمة عند الطلب عبر Google Translate مع تخزين فيTranslation.
التخطيط
302 نموذجاً عبر 73 ملف schema، مجمَّعة حسب المجال. نماذج الكتالوج تحذف schoolId عمداً — هي محتوى مرجعي على مستوى المنصة يُشارَك بين المدارس عبر نمط الجسر.
| المجال | النماذج | الملفات |
|---|---|---|
| الأساس (school، year، term، period، subscription) | 12 | school.prisma |
| Auth والمستخدمون | 7 | auth.prisma |
| الأشخاص (طلاب، معلمون، أهل، كادر) | 32 | students.prisma, staff.prisma, staff-member.prisma, profile.prisma |
| البنية الأكاديمية | 13 | academic.prisma, subjects.prisma, classrooms.prisma, enrollment.prisma |
| الكتالوج (عام) | 22+ | catalog.prisma, bridge.prisma, chapter.prisma, lesson.prisma، إلخ |
| الحضور | 33+ | attendance.prisma, attendance-enhanced.prisma, geo-attendance.prisma |
| الامتحانات والتقييم | 40+ | exam.prisma, school-exam.prisma, school-qbank.prisma, quiz.prisma, quiz-game.prisma, quick-assessments.prisma، إلخ |
| المالية | 36 | finance-core.prisma, finance-fees.prisma, finance-invoices.prisma, finance-payroll.prisma, finance-banking.prisma, finance-budgets.prisma, finance-reports.prisma, subscription.prisma |
| المراسلة | 11 | messages.prisma |
| الإشعارات | 7 | notifications.prisma |
| 5 | whatsapp.prisma | |
| الإعلانات | 5 | announcement.prisma |
| القبول | 12 | admission.prisma, visit.prisma, membership.prisma, promotion.prisma |
| Lumos / LMS | 8 | stream.prisma |
| الجدول | 9 | timetable.prisma, schedule.prisma |
| النقل | 7 | transportation.prisma |
| المكتبة | 4 | book.prisma, school-book.prisma, textbook.prisma |
| الملفات / الوثائق | 15 | files.prisma, file-record.prisma, document.prisma, document-processing.prisma, image.prisma, video.prisma |
| تدقيق / أمن / متفرق | 10 | audit.prisma, legal.prisma, webhooks.prisma, task.prisma, translation.prisma, purge-tokens.prisma |
عزل متعدّد المستأجرين
نموذج المدرسة
model School {
id String @id @default(cuid())
name String
domain String @unique // مثلاً "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)
}قواعد الاستعلام
// صحيح — اشمل دائماً schoolId
await db.student.findMany({ where: { schoolId } })
// خاطئ — يسرّب البيانات بين المستأجرين
await db.student.findMany()كل نموذج Prisma خاص بالمستأجر يملك:
- حقل
schoolId String. - علاقة
school School @relation(...)معonDelete: Cascade. @@unique([schoolId, ...])كي تكون التصادمات بين المستأجرين مستحيلة.@@index([schoolId])لأداء الاستعلام.
نماذج بدون schoolId
نحو 44 نموذجاً يحذف schoolId عمداً:
- رموز Auth (4):
Account,VerificationToken,PasswordResetToken,TwoFactorToken— مرتبطة بالبريد، عابرة. - عوام الكتالوج (22+):
Subject,Chapter,Lesson,Book,Textbook,CurriculumStandard، إلخ — محتوى مرجعي على مستوى المنصة، نماذج الجسر تُحدّد نطاق الاختيارات لكل مدرسة. - مستويات الاشتراك (1): تسعير على مستوى المنصة.
بنية LMS مزدوجة
نظامان متوازيان للمحتوى:
| النظام | النطاق | النماذج | المصدر |
|---|---|---|---|
| Lumos | بنطاق المدرسة (schoolId) | StreamCourse, StreamCategory, StreamEnrollment, StreamLesson, StreamCertificate | مساقات تنشئها المدرسة |
| Catalog | على مستوى المنصة (بدون schoolId) | Subject, Chapter, Lesson, Material, Question | منتقى |
نماذج bridge.prisma (SubjectSelection, BookSelection, ContentOverride, InstructorPreference) تربط محتوى الكتالوج بالبنية الأكاديمية الخاصة بالمدرسة. المدارس تختار المواد التي تستخدمها وتطبّق تجاوزات بدون لمس الأصول المشتركة.
أدوار المستخدمين
ثمانية أدوار في prisma/models/auth.prisma:UserRole. راجع تعدد المستأجرين لمصفوفة RBAC.
| الدور | نطاق المدرسة | الاستخدام |
|---|---|---|
DEVELOPER | كل المدارس | إدارة المنصة، عمليات عابرة للمستأجرين |
ADMIN | واحدة | إدارة المدرسة، الإشراف على المستخدمين |
TEACHER | واحدة | إدارة الفصل، التقييم، الحضور |
STUDENT | واحدة | درجاته، حضوره، واجباته |
GUARDIAN | واحدة | بيانات الطالب المرتبط، المدفوعات، الإشعارات |
ACCOUNTANT | واحدة | فوترة، تقارير مالية (لا أكاديميات) |
STAFF | واحدة | دعم تشغيلي |
USER | لا شيء | افتراضي بعد التسجيل قبل الإعداد |
الاشتراك والفوترة
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])
}إضافة إلى BillingPaymentMethod, BillingHistory, UsageMetrics, CreditNote, BillingPreferences, AppliedDiscount لدورة الفوترة الكاملة.
القانوني والامتثال
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())
}تخزين بلغة واحدة
المحتوى يُخزَّن بلغة واحدة مع حقل lang. الترجمة عند الطلب عبر Google Translate API، مع تخزين في 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" أو "en"
targetLanguage String // "ar" أو "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"
// مخزَّن بالعربية، يُعرَض بالإنجليزية → يترجم ويخزّن
const title = await getText("مرحبا", "ar", "en", schoolId)
// نفس اللغة → يعود مباشرة بدون استدعاء API
const title = await getText("مرحبا", "ar", "ar", schoolId)نماذج تحمل lang
Announcement, AnnouncementTemplate, NotificationTemplate, Subject, Class, YearLevel, Department, AcademicLevel, AcademicGrade, AcademicStream, AttendanceBadge, AttendanceCompetition, Chapter, Lesson, Book, Textbook, Material, CurriculumStandard, GradingScheme, QuickAssessment, Video, StreamCourse, StreamCategory. الافتراضي lang = "ar" لمحتوى المدرسة، وlang = "en" لـStream/LMS.
الأداء
فهارس موصى بها:
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);إرشادات:
- فلتر دائماً بـ
schoolIdأولاً فيWHERE. - استخدم فهارس مركّبة لتركيبات الحقول المُستعلَم عنها كثيراً.
- connection pooling عبر Neon serverless.
- read replicas للتقارير والتحليلات.
النشر
البيئة
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=2000الترحيلات
pnpm prisma generate # إعادة توليد client
pnpm prisma migrate dev --name <name> # محليًا فقط — إنشاء + تطبيق ترحيل
pnpm db:seed:single <name> # seed وحدة — لا تستخدم pnpm db:seed
prisma migrate deployأداة خاطئة على الإنتاج، وهذه الصفحة كانت توصي بها. تم التحقق في 2026-08-18: قاعدة بيانات الإنتاج لا تحتوي جدول_prisma_migrations— فهي مُدارة عبرdb push— لذا سيحاولmigrate deployتطبيق كل الترحيلات الـ38 من الصفر فوق مخطط موجود أصلًا. لا تشغّلها على الإنتاج.
لتغيير مخطط الإنتاج، خذ الـ DDL من المقارنة وطبّق ما تقصده فقط:
# المجلد، وليس prisma/schema.prisma. ملف prisma.config.ts يحمّل النماذج من prisma/، لذا
# الإشارة إلى ملف مصدر البيانات وحده تُنتج نموذجًا فارغًا — عندها تدّعي المقارنة حذف 719
# مفتاحًا أجنبيًا و328 جدولًا. هذا أثر جانبي للأمر نفسه وليس انحرافًا حقيقيًا.
pnpm prisma migrate diff --from-url "$PROD_DATABASE_URL" --to-schema-datamodel prisma --scriptاقرأ ما يطبعه، وطبّق العبارات التي تقصدها فقط، ثم أعد تشغيل المقارنة للتأكد من أنها فارغة.
إضافة عمود يقبل null بدون قيمة افتراضية تغييرٌ في الفهرس فقط في Postgres — فوري، بدون إعادة
كتابة الجدول، وبدون المساس بالصفوف الموجودة.
الترتيب مهم: قاعدة البيانات تتغيّر قبل الكود الذي يتوقّعها. بناء Vercel يشغّل
prisma generate فقط (انظر postinstall) ولا يشغّل migrate deploy أبدًا، لذا أي حقل يُشحن
قبل عموده يجعل كل استعلام على ذلك النموذج يفشل في الإنتاج.
العمليات المدمّرة (db execute, db push --accept-data-loss, migrate reset, DROP TABLE, TRUNCATE) محظورة عبر hooks. استخدم بروتوكول Branch-Before-Touch على Neon للعمليات الخطرة.
مدرسة العرض
مستأجر عرض واحد (demo.databayt.org) مع محاكاة سودانية كاملة لـK-12. راجع Safe seeds.
| الكيان | العدد | ملاحظات |
|---|---|---|
| المستخدمون | 3,105 | كل الأدوار، بريد @databayt.org |
| المعلمون | 100 | أسماء عربية، 6 أقسام |
| الطلاب | 970 | KG1–الصف 12 |
| أولياء الأمور | ~1,940 | اثنان لكل طالب |
| الأقسام | 6 | عربي أساسي + حقل lang |
| المواد | 19 | عربي أساسي + حقل lang |
| المستويات | 14 | KG1–12 |
| الفصول | 28 | مختبرات، غرف، مرافق خاصة |
| الفصول الدراسية | 187 | تركيبات بمستوى المادة |
انظر أيضاً
- تعدد المستأجرين — تدفق الطلب، RBAC، ضمانات العزل
- التدويل — عقد i18n
- الترجمة — تغطية القاموس
- الكتالوج — محتوى عام + نمط الجسر
- Seeds — كتالوج الـseeds الكامل
- Safe seeds — حسابات تطوير محمية