Skip to content
95
API Endpoints
18
Entities
88
Use Cases
4
Roles
672
Tests
Architecture
API Layer Controllers · Middleware · CORS Application Layer CQRS · MediatR · Validation · DTOs Domain Layer Entities · Enums · Interfaces Infrastructure EF Core · SQL · Hangfire External SMTP · Seq · Files HTTP 88 Use Cases 18 Entities
.NET 9 C# Clean Arch CQRS
Request Pipeline
Client Request CORS FlutterApp origin Rate Limiting per role JWT + Localization ?lang=ar Security + Logging MediatR Pipeline ValidationBehavior → Handler → Repository → SQL Response (JSON / File)
Endpoints by Controller
Domain Model — 18 Entities
User Id · FullName · Email · PasswordHash Role · IsActive · IsVerified Teacher NationalId · Bio · Rating Student StudentCode · ParentId Parent Subject Group Name · GradeLevel · Period GroupSchedule Session Date · Type · Topic · Headers SessionHeaderDefinition Label · ColumnType · MaxScore StudentSession Attended Assessment Score · MaxScore StudentsGroups Badge StudentsBadges Leaderboard Rank · Score PaymentRecord Notification UploadLog TPT TPT FK 1:N 1:N 1:N headers pivot via join →User
Background Jobs (Hangfire)
ProcessExcelJob
Legacy + Session-bound Excel parsing
Queue: uploads | strategy: error|replace|skip
LeaderboardJob
Hourly ranking updates
Recurring: hourly
BadgeAwardingJob
Daily badge evaluation
Recurring: daily
CleanupOldFilesJob
Delete files > 30 days
Recurring: daily

Teacher Journey — 50 Endpoints

Complete API reference grouped by domain. Every endpoint with real request/response examples.

🔒 Authentication 3 endpoints

Register Account

Create a teacher account. Returns tokens immediately but email must be verified before login.

Request / Response
Request Body
{
  "fullName": "Ahmed Hassan",
  "email": "ahmed@gmail.com",
  "username": "ahmed_h",
  "password": "Teacher@123",
  "phoneNumber": "+201012345678",
  "dateOfBirth": "1990-05-15T00:00:00",
  "role": "Teacher"
}
Response 201
{
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "dGhpcyBpcyBhIHJlZnJl...",
    "expiresAt": "2026-09-12T12:15:00Z",
    "role": "Teacher",
    "userId": "b0000000-0000-00:00:00-000000000001"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Public endpoint — no authentication required.
Rate Limit: 10 requests/minute.
Auth: None (public).
Response Fields: nationalId, subscriptionPlan (string like "Basic"/"Premium"), subscriptionEnd (nullable DateTime), bio (nullable), rating (byte 0-5), subjectName (from linked Subject).
Errors: 401 (no/invalid token), 403 (wrong role).
Enums:
· role: "Teacher" | "Student" | "Parent" — Admin cannot self-register.
Validation:
· fullName: required, max 100 chars.
· email: required, valid format, must be unique.
· username: required, 3-50 chars, must be unique.
· password: required, min 8 chars.
· phoneNumber: required, E.164 format regex ^\+?\d{7,15}$.
· dateOfBirth: required, must be in the past.
Errors: 400 (validation), 409 (duplicate email/username).

Verify Email

Enter the 6-digit numeric code sent to your email. Code expires in 2 minutes.

Request / Response
Request Body
{ "email": "ahmed@gmail.com", "code": "482916" }
Response 200
{ "data": { "message": "success.emailVerified" }, "meta": { "timestamp": "...", "requestId": "..." } }
Prerequisites: Must have called POST /auth/register first. A 6-digit code was sent to the registered email.
Rate Limit: 10 requests/minute per IP.
Validation:
· email: required, valid format, must match a pending verification.
· code: required, exactly 6 numeric digits (^[0-9]{6}$).
Errors: 400 (validation), 401 (expired/invalid code — code expires in 2 minutes).
Resend: Call POST /auth/resend-verification with { "email": "..." } to get a new code.

Login

Authenticate with email + password. Returns JWT access (15min) + refresh (7 days) tokens.

Request / Response
Request Body
{ "email": "ahmed@gmail.com", "password": "Teacher@123" }
Response 200
{
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "dGhpcyBpcyBhIHJlZnJl...",
    "expiresAt": "2026-09-12T12:15:00Z",
    "role": "Teacher",
    "userId": "b0000000-0000-0000-0000-000000000001"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Must have verified email via POST /auth/verify-email.
Rate Limit: 10 requests/minute per IP.
Validation:
· email: required, valid format.
· password: required, min 6 chars (note: registration requires 8, login only 6 for backward compat).
Errors: 400 (validation), 401 (bad credentials OR unverified email).
Token Lifecycle: Access token = 15 minutes. Refresh token = 7 days. Use Bearer token in Authorization header for all subsequent requests. Refresh via POST /auth/refresh-token when access expires.
👤 Profile & Dashboard 4 endpoints

Get My Profile

View your full teacher profile including subscription status.

Request / Response
Response 200
{
  "data": {
    "id": "b0000000-0000-0000-0000-000000000001",
    "fullName": "Ahmed Hassan",
    "email": "ahmed@gmail.com",
    "username": "ahmed_h",
    "phoneNumber": "+201012345678",
    "dateOfBirth": "1990-05-15T00:00:00",
    "nationalId": "29005151234567",
    "subscriptionPlan": "Premium",
    "subscriptionEnd": "2027-09-12T00:00:00Z",
    "bio": "Biology teacher with 10 years experience",
    "rating": 5,
    "subjectName": "Biology",
    "createdAt": "2026-01-15T00:00:00Z"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Query: search (optional, matches name/email), filter ("my-students"|"available"), page (default 1), pageSize (default 20, max 100).
Behavior: "my-students" = students enrolled in your groups. "available" = students not yet in any of your groups. No filter = all students visible to you.
Errors: 401 (no token), 403 (wrong role).

Update My Profile

Update your name, phone, bio, and subscription plan.

Request / Response
Request Body
{
  "fullName": "Ahmed Hassan",
  "phoneNumber": "+201012345678",
  "bio": "Biology teacher — 10 years experience",
  "subscriptionPlan": "Premium"
}
Response 200 — same shape as GET profile
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Validation: fullName (required, max 100), phoneNumber (required, E.164 regex). Email change triggers re-verification (old email stays active until new one is verified).
Errors: 400 (validation), 401 (no token), 403 (wrong role), 409 (duplicate email if changed).

Teacher Dashboard

Full dashboard: profile, overview stats, week-over-week trends, top/bottom groups, recent uploads, top students, platform comparison, students at risk. Fixed-size summary endpoint — no pagination. Cached 2 minutes.

Request / Response
Response 200 (key fields)
{
  "data": {
    "profile": { "fullName": "Ahmed Hassan", "email": "ahmed@gmail.com", "subject": "Biology", "subscriptionPlan": "Premium" },
    "overview": {
      "totalGroups": 8, "activeGroups": 6, "totalStudents": 142,
      "totalSessionsThisMonth": 18, "pendingPaymentsEGP": 3500.00,
      "totalUploadsThisMonth": 12, "scoreTrend": 4.5,
      "topPerformingGroup": "Biology - Secondary 2", "topPerformingGroupScore": "88.5%",
      "studentsAtRiskCount": 5, "uploadStreakWeeks": 6,
      "classAverageScore": 76.3, "totalSessionsAllTime": 234
    },
    "trends": {
      "scoreChangePercent": 5.2, "attendanceChangePercent": 3.1,
      "studentsChange": 8, "scoreTrend": "up", "attendanceTrend": "up"
    },
    "topGroups": {
      "top": [
        { "groupId": "...", "name": "Biology - Secondary 2", "subject": "Biology", "gradeLevel": "SecondaryTwo", "period": "First Term", "studentCount": 45, "averageScore": 88.5, "isActive": true, "attendanceRate": 94.0, "trend": "up" }
      ],
      "bottom": [
        { "groupId": "...", "name": "Math - Secondary 1", "subject": "Mathematics", "gradeLevel": "SecondaryOne", "period": "First Term", "studentCount": 22, "averageScore": 54.2, "isActive": true, "attendanceRate": 72.0, "trend": "down" }
      ]
    },
    "recentUploads": [
      { "uploadId": "...", "fileName": "session_2026-09-10.xlsx", "groupName": "Biology - Secondary 2", "status": "Completed", "rowsProcessed": 45, "uploadedAt": "2026-09-12T14:30:00Z" }
    ],
    "topStudents": [
      { "gradeLevel": "SecondaryTwo", "groupName": "Biology - Secondary 2", "students": [
        { "studentId": "...", "fullName": "Ali Hassan", "studentCode": "STU-003", "rank": 1, "avgScore": 95.5, "attendanceRate": 100.0 }
      ]}
    ],
    "unreadNotifications": 3,
    "needsAttention": [
      { "studentId": "...", "fullName": "Omar Ali", "groupName": "Math - Secondary 1", "reason": "Low score", "currentScore": 38.0, "attendanceRate": 55.0 }
    ],
    "platformComparison": { "yourAverageScore": 76.3, "schoolAverageScore": 71.0, "yourRank": 3, "totalTeachers": 15, "performanceLabel": "Above Average" }
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Caching: IMemoryCache, 2 minutes TTL. Cache key: teacher_home_{userId}.
Response Fields: profile, overview, trends (week-over-week), topGroups (top 2 + bottom 2 by average score), recentUploads (last 3 uploads with status), topStudents (grouped by grade level, top 3 per grade from best group), unreadNotifications, needsAttention (nullable — students at risk), platformComparison (nullable).
No pagination — this is a fixed-size summary endpoint.
Errors: 401 (no token), 403 (wrong role).

Dashboard Trends

Score and attendance change percentages for sparkline charts.

Request / Response
Response 200
{
  "data": {
    "scoreChangePercent": 5.2,
    "attendanceChangePercent": 3.1,
    "studentsChange": 8,
    "scoreTrend": "up",
    "attendanceTrend": "up"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Response Fields: scoreChangePercent (double, positive = improvement), attendanceChangePercent, studentsChange (int, net change), scoreTrend ("up"|"down"|"stable"), attendanceTrend ("up"|"down"|"stable").
Use Case: Drive sparkline charts on the dashboard. Compare with previous period to show trends.
Errors: 401 (no token), 403 (wrong role).
👥 Groups 6 endpoints

Create Group

Define group name, grade level, subject, period, and optional weekly schedules.

Request / Response
Request Body
{
  "name": "Biology - Secondary 2",
  "academicYear": "2025-2026",
  "gradeLevel": 2,
  "period": "First Term",
  "subjectId": "a1b2c3d4-5678-9abc-def0-1234567890ab",
  "schedules": [
    { "dayOfWeek": "Monday", "startTime": "14:00", "endTime": "16:00" },
    { "dayOfWeek": "Wednesday", "startTime": "14:00", "endTime": "16:00" }
  ]
}
Response 200
{
  "data": {
    "id": "c5d6e7f8-9abc-def0-1234-567890abcdef",
    "name": "Biology - Secondary 2",
    "academicYear": "2025-2026",
    "gradeLevel": "SecondaryTwo",
    "period": "First Term",
    "isActive": true,
    "teacherId": "b0000000-0000-0000-0000-000000000001",
    "subjectName": "Biology",
    "createdAt": "2026-09-12T10:00:00Z",
    "studentCount": 0, "averageScore": 0, "attendanceRate": 0, "activeSessions": 0,
    "schedules": [
      { "id": "...", "dayOfWeek": "Monday", "startTime": "14:00", "endTime": "16:00", "isActive": true }
    ]
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. SubjectId must reference an existing Subject (GET /subjects to list).
Rate Limit: 30 req/min.
Enums:
· gradeLevel: 1=SecondaryOne, 2=SecondaryTwo, 3=SecondaryThree. Must be integer, not string.
· period: "First Term" | "Second Term". Restricted by CHECK constraint in DB.
· dayOfWeek (in schedules): "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday".
Validation: name (required, max 200 chars), academicYear (required, e.g. "2025-2026"), subjectId (required, must be valid GUID).
Errors: 400 (validation), 401 (no token), 403 (wrong role), 404 (subject not found).

List My Groups

Paginated list of all your groups with stats.

Request / Response
Query Parameters
gradeLevel: string?  (filter by grade)
page: int = 1
pageSize: int = 20
Response 200
{
  "data": {
    "items": [
      {
        "id": "c5d6e7f8-...", "name": "Biology - Secondary 2",
        "academicYear": "2025-2026", "gradeLevel": "SecondaryTwo",
        "period": "First Term", "isActive": true,
        "subjectName": "Biology", "studentCount": 50,
        "averageScore": 88.5, "attendanceRate": 94.0,
        "activeSessions": 3, "lastUploadDate": "2026-09-12T14:30:00Z",
        "trend": "up",
        "schedules": [
          { "id": "...", "dayOfWeek": "Monday", "startTime": "14:00", "endTime": "16:00", "isActive": true }
        ]
      }
    ],
    "page": 1, "pageSize": 20, "totalCount": 5
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}

Get Group Detail

Single group with full details including schedules.

Request / Response
Response 200 — same shape as single item in list response above
Prerequisites: Logged in with Teacher. Group must belong to this teacher.
Rate Limit: 30 req/min.
Errors: 401 (no token), 403 (not your group), 404 (group not found).

Update Group

Update group name, grade, period, subject. Schedules are managed separately.

Request / Response
Request Body
{
  "name": "Biology - Secondary 2 (Updated)",
  "academicYear": "2025-2026",
  "gradeLevel": 2,
  "period": "Second Term",
  "subjectId": "a1b2c3d4-5678-9abc-def0-1234567890ab"
}
Response 200 — same shape as GroupResponseDto

Delete Group

Soft-delete: sets IsActive = false. Preserves all data.

Request / Response
Response 200
{ "data": { "message": "Group deleted successfully" }, "meta": { "timestamp": "...", "requestId": "..." } }
Prerequisites: Logged in with Teacher. Group must belong to this teacher.
Rate Limit: 30 req/min.
Behavior: Soft-delete — sets IsActive = false. All sessions, students, and data are preserved. Group disappears from default list queries but can still be accessed by ID.
Errors: 401 (no token), 403 (not your group), 404 (group not found).

Duplicate Group

Clone a group with its schedules. Students are NOT copied.

Request / Response
Request Body
{ "name": "Biology - Secondary 2 (Copy)" }
Response 200
{
  "data": {
    "id": "...", "name": "Biology - Secondary 2 (Copy)",
    "academicYear": "2025-2026", "gradeLevel": "SecondaryTwo",
    "period": "First Term", "subjectId": "a1b2c3d4-..."
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Source group must belong to this teacher.
Rate Limit: 30 req/min.
Behavior: Clones group metadata + schedules. Does NOT copy students, sessions, or upload logs. New group is independent.
Errors: 400 (validation), 401 (no token), 403 (not your group), 404 (source group not found).
📅 Schedules 3 endpoints

Add Schedules to Group

Add weekly time slots to an existing group.

Request / Response
Request Body (array)
[
  { "dayOfWeek": "Tuesday", "startTime": "15:00", "endTime": "17:00" },
  { "dayOfWeek": "Thursday", "startTime": "15:00", "endTime": "17:00" }
]
Response 200
{
  "data": [
    { "id": "...", "dayOfWeek": "Tuesday", "startTime": "15:00", "endTime": "17:00", "isActive": true },
    { "id": "...", "dayOfWeek": "Thursday", "startTime": "15:00", "endTime": "17:00", "isActive": true }
  ],
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Group must belong to this teacher.
Rate Limit: 30 req/min.
Enums: dayOfWeek: "Monday"|"Tuesday"|"Wednesday"|"Thursday"|"Friday"|"Saturday"|"Sunday". startTime/endTime: "HH:mm" format (24h).
Errors: 400 (validation), 401 (no token), 403 (not your group), 404 (group not found).

Update Schedule

Change day or time of an existing schedule slot.

Request / Response
Request Body
{ "dayOfWeek": 1, "startTime": "16:00", "endTime": "18:00" }
Response 200 — empty object
Prerequisites: Logged in with Teacher. Schedule must belong to a group owned by this teacher.
Rate Limit: 30 req/min.
Enums: dayOfWeek: 0=Sunday, 1=Monday, ..., 6=Saturday (System.DayOfWeek integer enum).
Errors: 400 (validation), 401 (no token), 403 (not your schedule), 404 (schedule not found).

Delete Schedule

Remove a weekly time slot from a group.

Request / Response
Response 200 — empty object
Prerequisites: Logged in with Teacher. Schedule must belong to a group owned by this teacher.
Rate Limit: 30 req/min.
Behavior: Permanently deletes the schedule slot. Cannot be undone.
Errors: 401 (no token), 403 (not your schedule), 404 (schedule not found).
🎓 Student Management 9 endpoints

List Students

Browse students visible to you. Filter by "my-students" or "available".

Request / Response
Query Parameters
search: string?     (search by name/email)
filter: string?     ("my-students" | "available")
page: int = 1
pageSize: int = 20
Response 200
{
  "data": {
    "items": [
      {
        "studentId": "d4e5f6a7-89ab-cdef-0123-456789abcdef",
        "fullName": "Sara Mohamed",
        "email": "sara@student.com",
        "studentCode": "STU-001",
        "isEnrolled": false,
        "enrolledGroups": null,
        "stats": { "averageScore": 0, "attendanceRate": 0, "totalSessions": 0, "attendedSessions": 0, "assessmentCount": 0, "lastActivityDate": null, "scoreTrend": "stable" }
      }
    ],
    "page": 1, "pageSize": 20, "totalCount": 45
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}

Look Up Student by Code

Find a specific student using their unique student code.

Request / Response
Response 200 — same StudentResponseDto shape

Enroll Student by ID

Add a student to a group by their GUID.

Request / Response
Request Body
{ "studentId": "d4e5f6a7-89ab-cdef-0123-456789abcdef" }
Response 200
{
  "data": {
    "studentId": "d4e5f6a7-...",
    "fullName": "Sara Mohamed",
    "email": "sara@student.com",
    "studentCode": "STU-001",
    "isEnrolled": true,
    "enrolledGroups": [{ "groupId": "c5d6e7f8-...", "groupName": "Biology - Secondary 2" }],
    "stats": { "averageScore": 0, "attendanceRate": 0, "totalSessions": 0 }
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Group must belong to this teacher. Student must exist and not already be enrolled in this group.
Rate Limit: 30 req/min.
Behavior: Creates a StudentsGroups join record. If student has no StudentCode yet, one is auto-generated (format: "STU-{nnn}").
Errors: 400 (validation), 401 (no token), 403 (not your group), 404 (student not found), 409 (already enrolled).

Enroll Student by Code

Add a student to a group using their student code. No request body needed.

Request / Response
Query: groupId={groupId} (required)
Response 200 — same StudentResponseDto shape

Get Student Progress

Detailed progress: attendance, scores, per-group breakdown.

Request / Response
Response 200
{
  "data": {
    "studentId": "d4e5f6a7-...",
    "fullName": "Sara Mohamed",
    "overallAttendancePercentage": 92.0,
    "overallAverageWrittenScore": 85.5,
    "overallAverageAssignmentScore": 88.0,
    "overallProgressPercentage": 87.0,
    "totalSessionsAcrossAllGroups": 25,
    "perGroupProgress": [
      {
        "studentName": "Sara Mohamed",
        "totalSessions": 12,
        "attendedSessions": 11,
        "attendancePercentage": 91.7,
        "averageWrittenScore": 86.0,
        "averageAssignmentScore": 89.0,
        "overallProgressPercentage": 87.5,
        "recentAssessments": [
          { "examName": "written_exam", "topic": "Mitosis", "scorePercentage": 90, "maxScore": 50, "sessionDate": "2026-09-12T14:00:00" }
        ]
      }
    ]
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}

Get Student's Parent Contact

Retrieve parent name, email, and phone for a student.

Request / Response
Response 200
{
  "data": {
    "parentId": "f1a2b3c4-...",
    "fullName": "Mohamed Ali",
    "email": "mohamed@parent.com",
    "phoneNumber": "+201055512345"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}

Unenroll Student

Remove a student from a group. Preserves historical data.

Request / Response
Response 200
{ "data": { "message": "Student unenrolled successfully" }, "meta": { "timestamp": "...", "requestId": "..." } }
Prerequisites: Logged in with Teacher. Student must be enrolled in this group.
Rate Limit: 30 req/min.
Behavior: Removes the StudentsGroups join record. Historical session data (StudentSessions, Assessments) is preserved.
Errors: 401 (no token), 403 (not your group), 404 (student not in group).

Approve Student

Re-activate a previously deactivated student.

Request / Response
Response 200
{ "data": { "message": "Student approved successfully" }, "meta": { "timestamp": "...", "requestId": "..." } }
Prerequisites: Logged in with Teacher. Student must be deactivated (IsActive = false).
Rate Limit: 30 req/min.
Behavior: Sets Student.IsActive = true. Student can log in again and appears in group lists.
Errors: 401 (no token), 403 (wrong role), 404 (student not found).

Export Students as Excel

Download a spreadsheet of all students in a group with their stats.

Request / Response
Response 200 — binary .xlsx file
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="students_c5d6e7f8.xlsx"
📅 Sessions 7 endpoints

List All Sessions

All sessions across your groups, paginated.

Request / Response
Response 200
{
  "data": {
    "items": [
      {
        "sessionId": "e7f8a9b0-...",
        "groupName": "Biology - Secondary 2",
        "sessionDate": "2026-09-12T14:00:00",
        "sessionType": "Quiz",
        "attended": true,
        "score": 45,
        "maxScore": 50,
        "scorePercentage": 90.0,
        "comment": null
      }
    ],
    "page": 1, "pageSize": 20, "totalCount": 156
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Query: page (default 1), pageSize (default 20, max 100).
Behavior: Returns sessions across all groups owned by this teacher. Sorted by sessionDate descending (newest first).
Errors: 401 (no token), 403 (wrong role).

Create Session

Create a session with optional Excel header definitions. Headers define which columns your upload Excel will have.

Request / Response
Request Body
{
  "sessionDate": "2026-09-12T14:00:00",
  "sessionType": 1,
  "topic": "Cell Division - Mitosis",
  "description": "Chapter 3 review",
  "headers": [
    { "key": "attendance", "label": "Attendance", "labelArabic": "الحضور", "columnType": 0, "sortOrder": 0, "isRequired": true },
    { "key": "written_exam", "label": "Written Exam", "labelArabic": "الامتحان الكتابي", "columnType": 1, "maxScore": 50, "sortOrder": 1, "isRequired": true },
    { "key": "oral_quiz", "label": "Oral Quiz", "labelArabic": "الامتحان الشفهي", "columnType": 1, "maxScore": 20, "sortOrder": 2, "isRequired": false },
    { "key": "comment", "label": "Comment", "labelArabic": "ملاحظة", "columnType": 2, "sortOrder": 3, "isRequired": false }
  ]
}
Response 201
{
  "data": {
    "id": "e7f8a9b0-1234-5678-9abc-def012345678",
    "sessionDate": "2026-09-12T14:00:00",
    "sessionType": 1,
    "topic": "Cell Division - Mitosis",
    "description": "Chapter 3 review",
    "createdAt": "2026-09-12T10:30:00Z",
    "headers": [
      { "id": "...", "key": "attendance", "label": "Attendance", "labelArabic": "الحضور", "columnType": 0, "maxScore": null, "sortOrder": 0, "isRequired": true },
      { "id": "...", "key": "written_exam", "label": "Written Exam", "labelArabic": "الامتحان الكتابي", "columnType": 1, "maxScore": 50, "sortOrder": 1, "isRequired": true },
      { "id": "...", "key": "oral_quiz", "label": "Oral Quiz", "labelArabic": "الامتحان الشفهي", "columnType": 1, "maxScore": 20, "sortOrder": 2, "isRequired": false },
      { "id": "...", "key": "comment", "label": "Comment", "labelArabic": "ملاحظة", "columnType": 2, "maxScore": null, "sortOrder": 3, "isRequired": false }
    ]
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. Group must exist and belong to this teacher.
Rate Limit: 30 req/min.
Enums:
· sessionType: 0=Exam, 1=Quiz, 2=Homework, 3=Oral, 4=Attendance. Integer, not string.
· columnType (in headers): 0=Attendance, 1=Score, 2=Comment. Integer, not string.
Validation: sessionDate (required), topic (required), description (optional). Headers array items: key (required, unique per session), label (required), columnType (required), sortOrder (required, 0-based), isRequired (default false). maxScore required when columnType=1 (Score).
Behavior: If headers omitted, defaults to Attendance + Written 60 + Assignment 20 + Comment. Use copyHeadersFromSessionId to reuse headers from a previous session (headers param is ignored if this is set).
Errors: 400 (validation), 401 (no token), 403 (wrong role), 404 (group not found).

Get Session Detail

Full session with all student sessions and assessment breakdowns.

Request / Response
Response 200
{
  "data": {
    "sessionId": "e7f8a9b0-...",
    "sessionDate": "2026-09-12T14:00:00",
    "sessionType": "Quiz",
    "topic": "Cell Division - Mitosis",
    "description": "Chapter 3 review",
    "groupName": "Biology - Secondary 2",
    "subjectName": "Biology",
    "teacherName": "Ahmed Hassan",
    "studentSessions": [
      {
        "studentSessionId": "...",
        "studentId": "d4e5f6a7-...",
        "studentName": "Sara Mohamed",
        "studentCode": "STU-001",
        "attended": true,
        "assessments": [
          { "assessmentId": "...", "examName": "written_exam", "topic": "", "maxScore": 50, "scorePercentage": 45, "teacherComment": "Good work" },
          { "assessmentId": "...", "examName": "oral_quiz", "topic": "", "maxScore": 20, "scorePercentage": 18, "teacherComment": "Excellent" }
        ]
      }
    ]
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Session must belong to this teacher.
Rate Limit: 30 req/min.
Response: Full session with all StudentSessions and their Assessments. Includes group name, subject name, teacher name.
Errors: 401 (no token), 403 (not your session), 404 (session not found).

Update Session

Change session date, topic, description, or type.

Request / Response
Request Body (all fields optional)
{
  "sessionDate": "2026-09-13T14:00:00",
  "topic": "Cell Division - Meiosis",
  "description": "Updated topic",
  "sessionType": 0
}
Response 200
{
  "data": {
    "id": "e7f8a9b0-...",
    "sessionDate": "2026-09-13T14:00:00",
    "sessionType": 0,
    "topic": "Cell Division - Meiosis",
    "description": "Updated topic",
    "createdAt": "2026-09-12T10:30:00Z"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Session must belong to this teacher.
Rate Limit: 30 req/min.
Validation: All fields optional. Only provided fields are updated (partial update). sessionType uses same enum as create (0-4).
Errors: 400 (validation), 401 (no token), 403 (not your session), 404 (session not found).

Delete Session

Permanently delete a session and all its assessments.

Request / Response
Response 204 — No Content

Mark Attendance

Bulk mark attendance for all students in a session.

Request / Response
Request Body
{
  "attendances": [
    { "studentId": "d4e5f6a7-...", "attended": true },
    { "studentId": "a1b2c3d4-...", "attended": false },
    { "studentId": "e5f6a7b8-...", "attended": true }
  ]
}
Response 200 — empty object
Prerequisites: Logged in with Teacher. Session must belong to this teacher. Students must be enrolled in the session's group.
Rate Limit: 30 req/min.
Behavior: Bulk operation. Creates or updates StudentSession records with the attended flag. Idempotent — calling again overwrites previous attendance.
Errors: 400 (validation), 401 (no token), 403 (not your session), 404 (session/student not found).
📋 Session Headers 2 endpoints

Get Session Headers

View the Excel column configuration for a session.

Request / Response
Response 200
{
  "data": [
    { "id": "...", "key": "attendance", "label": "Attendance", "labelArabic": "الحضور", "columnType": 0, "maxScore": null, "sortOrder": 0, "isRequired": true },
    { "id": "...", "key": "written_exam", "label": "Written Exam", "labelArabic": "الامتحان الكتابي", "columnType": 1, "maxScore": 50, "sortOrder": 1, "isRequired": true },
    { "id": "...", "key": "oral_quiz", "label": "Oral Quiz", "labelArabic": "الامتحان الشفهي", "columnType": 1, "maxScore": 20, "sortOrder": 2, "isRequired": false },
    { "id": "...", "key": "comment", "label": "Comment", "labelArabic": "ملاحظة", "columnType": 2, "maxScore": null, "sortOrder": 3, "isRequired": false }
  ],
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Session must belong to this teacher.
Rate Limit: 30 req/min.
Response: Array of SessionHeaderDefinitionDto (not wrapped in pagination). Sorted by sortOrder ascending.
Errors: 401 (no token), 403 (not your session), 404 (session not found).

Update Session Headers

Change the Excel column configuration. Locked (409) once scores are uploaded.

Request / Response
Request Body
{
  "headers": [
    { "key": "attendance", "label": "Attendance", "columnType": 0, "sortOrder": 0, "isRequired": true },
    { "key": "written_exam", "label": "Written Exam", "columnType": 1, "maxScore": 50, "sortOrder": 1, "isRequired": true },
    { "key": "bonus", "label": "Bonus Points", "columnType": 1, "maxScore": 10, "sortOrder": 2, "isRequired": false }
  ],
  "copyHeadersFromSessionId": null
}
Response 200
{
  "data": {
    "sessionId": "e7f8a9b0-...",
    "headers": [
      { "id": "...", "key": "attendance", "label": "Attendance", "columnType": 0, "maxScore": null, "sortOrder": 0, "isRequired": true },
      { "id": "...", "key": "written_exam", "label": "Written Exam", "columnType": 1, "maxScore": 50, "sortOrder": 1, "isRequired": true },
      { "id": "...", "key": "bonus", "label": "Bonus Points", "columnType": 1, "maxScore": 10, "sortOrder": 2, "isRequired": false }
    ]
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. Session must exist and belong to this teacher. Must NOT have any uploaded scores (StudentSessions with Assessments).
Rate Limit: 30 req/min.
Lock Condition: Returns 409 Conflict if scores already exist for this session. Delete existing scores first or create a new session.
Alternative: Use copyHeadersFromSessionId in the request body to copy headers from another session instead of defining them manually.
Errors: 400 (validation), 401 (no token), 403 (wrong role), 404 (session not found), 409 (scores exist — headers locked).
📈 Assessments 3 endpoints

Add Assessment

Add a score record for a student in a session. Creates or updates the assessment.

Request / Response
Request Body
{
  "studentId": "d4e5f6a7-89ab-cdef-0123-456789abcdef",
  "examName": "written_exam",
  "scorePercentage": 45,
  "maxScore": 50,
  "teacherComment": "Good understanding of mitosis phases"
}
Response 201
{
  "data": {
    "id": "...",
    "examName": "written_exam",
    "topic": "",
    "scorePercentage": 45,
    "maxScore": 50,
    "teacherComment": "Good understanding of mitosis phases"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. Session must exist. Student must be enrolled in the session's group. A StudentSession record must exist for this student+session combo.
Rate Limit: 30 req/min.
Validation: studentId (required, valid GUID), examName (required, must match a header key), scorePercentage (required, 0-100), maxScore (required, > 0).
Behavior: scorePercentage is stored as percentage (0-100), not raw score. The system calculates: rawScore = (scorePercentage / 100) * maxScore. Creates a new Assessment record linked to the StudentSession.
Errors: 400 (validation), 401 (no token), 403 (wrong role), 404 (session/student not found).

Update Assessment

Change score or comment on an existing assessment.

Request / Response
Request Body (both optional)
{ "scorePercentage": 48, "teacherComment": "Updated: excellent progress" }
Response 200
{
  "data": {
    "id": "...", "examName": "written_exam", "topic": "",
    "scorePercentage": 48, "teacherComment": "Updated: excellent progress"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Assessment must exist and belong to a session owned by this teacher.
Rate Limit: 30 req/min.
Validation: Both fields optional (partial update). scorePercentage: 0-100.
Errors: 400 (validation), 401 (no token), 403 (not your assessment), 404 (assessment not found).

Get Average Score

Quick summary: average, min, max scores and attendance for a session.

Request / Response
Response 200
{
  "data": {
    "sessionId": "e7f8a9b0-...",
    "sessionType": "Quiz",
    "sessionDate": "2026-09-12T14:00:00",
    "groupName": "Biology - Secondary 2",
    "totalStudents": 50,
    "attendedStudents": 46,
    "attendanceRate": 92.0,
    "averageScore": 82.8,
    "minScore": 35,
    "maxScore": 100,
    "totalAssessments": 138
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Session must belong to this teacher with uploaded scores.
Rate Limit: 30 req/min.
Response: Lightweight summary — faster than /report. TotalAssessments = total assessment records (one per score column per student).
Errors: 401 (no token), 403 (not your session), 404 (session not found).
📎 Excel Upload 5 endpoints

Download Template

Get an Excel template pre-filled with enrolled student names/codes AND your custom header columns.

Request / Response
Query Parameters
groupId: uuid?    (legacy mode: populates student names)
sessionId: uuid?  (session-bound mode: headers match session config)
Response 200 — binary .xlsx file
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="template_20260912.xlsx"
Prerequisites: Logged in with Teacher role. If sessionId provided, session must exist with configured headers. If groupId provided, group must have enrolled students.
Rate Limit: 5 req/min (stricter — file generation is expensive).
Modes:
· Session-bound (recommended): Pass sessionId. Template columns match session headers exactly. Student names/codes auto-filled from enrolled students.
· Legacy: Pass groupId only. Uses default headers (Attendance, Written 60, Assignment 20, Comment).
Errors: 401 (no token), 403 (wrong role), 404 (session/group not found).

Upload Excel

Fill the template with attendance + scores, then upload. Hangfire processes it async. Strategy: replace (overwrite), skip (keep existing), or error (reject if scores exist).

Request / Response
multipart/form-data
file: (binary .xlsx, max 10MB)
groupId: c5d6e7f8-9abc-def0-1234-567890abcdef
sessionId: e7f8a9b0-1234-5678-9abc-def012345678  (optional)
assessmentDate: 2026-09-12T14:00:00  (optional)
strategy: replace  (error | replace | skip)
Response 200
{
  "data": {
    "uploadId": "f9a0b1c2-3456-789a-bcde-f01234567890",
    "fileName": "template_20260912.xlsx",
    "status": "Queued",
    "uploadedAt": "2026-09-12T14:30:00Z"
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. File must be .xlsx, .xls, or .csv (max 10MB). Group must exist. If sessionId provided, session must have headers configured.
Rate Limit: 5 req/min (stricter — file upload is expensive).
Enums:
· strategy: "error" | "replace" | "skip". Query parameter, not body.
  - "error": Returns 409 if scores already exist for this session.
  - "replace": Deletes all existing StudentSessions + Assessments, creates fresh from Excel.
  - "skip": Keeps students who already have scores, only adds new students.
Behavior: File is saved to disk at uploads/excel/{yyyy}/{MM}/{dd}/{guid}_{filename}. Processing is async via Hangfire (ProcessExcelJob on "uploads" queue). Always returns status="Queued". Poll GET /upload-logs to check completion.
Errors: 400 (invalid file), 401 (no token), 403 (wrong role), 409 (strategy=error and scores exist), 413 (file too large).

List Upload Logs

Paginated upload history with status, row counts, and group info.

Request / Response
Response 200
{
  "data": {
    "items": [
      {
        "uploadId": "f9a0b1c2-...",
        "fileName": "template_20260912.xlsx",
        "fileSize": 24576,
        "status": 2,
        "uploadedAt": "2026-09-12T14:30:00Z",
        "completedAt": "2026-09-12T14:30:15Z",
        "rawProcessed": 50,
        "rawFailed": 0,
        "groupId": "c5d6e7f8-...",
        "groupName": "Biology - Secondary 2"
      }
    ],
    "page": 1, "pageSize": 10, "totalCount": 8
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Enums:
· status: 0=Queued, 1=Processing, 2=Completed, 3=Failed. Integer (UploadStatus enum).
Polling: After upload, poll this endpoint every 2-3 seconds. Check if status changed from 0/1 to 2 or 3.
Errors: 401 (no token), 403 (wrong role).

Get Upload Log Detail

Single upload log with full details.

Request / Response
Response 200 — same shape as single item in list

Retry Failed Upload

Re-queue a failed upload for processing.

Request / Response
Response 200 — empty object
Prerequisites: Logged in with Teacher role. Upload log must exist with status=3 (Failed). The uploaded file must still exist on disk (not cleaned up by CleanupOldFilesJob).
Rate Limit: 30 req/min.
Behavior: Re-queues the same file for processing with the same strategy. Creates a new Hangfire job. The upload status resets to Queued (0).
Errors: 401 (no token), 403 (wrong role), 404 (upload log not found), 409 (status is not Failed).
📊 Reports & Export 3 endpoints

Session Report

Professional report: column breakdown, highlights, chart series, needs-attention, comparison with previous session. Cached 1 minute.

Request / Response
Response 200 (key fields)
{
  "data": {
    "session": {
      "sessionId": "e7f8a9b0-...", "sessionDate": "2026-09-12T14:00:00",
      "sessionType": "Quiz", "topic": "Cell Division - Mitosis",
      "groupName": "Biology - Secondary 2", "subjectName": "Biology"
    },
    "analytics": {
      "totalStudents": 50, "attendedStudents": 46, "attendanceRate": 92.0,
      "averageScore": 82.8, "minScore": 35, "maxScore": 100, "medianScore": 85.0,
      "passCount": 42, "failCount": 4, "passRate": 87.5,
      "gradeDistribution": { "A": 31, "B": 33, "C": 21, "D": 7 }
    },
    "columnBreakdown": [
      { "key": "written_exam", "label": "Written Exam", "maxScore": 50, "average": 38.5, "min": 15, "max": 50, "passRate": 90.0, "assessedCount": 46 },
      { "key": "oral_quiz", "label": "Oral Quiz", "maxScore": 20, "average": 16.2, "min": 8, "max": 20, "passRate": 85.0, "assessedCount": 46 }
    ],
    "highlights": {
      "topStudents": [{ "studentId": "...", "studentName": "Ali Hassan", "studentCode": "STU-003", "average": 98.5, "delta": 3.2 }],
      "atRisk": [{ "studentId": "...", "studentName": "Omar Ali", "studentCode": "STU-012", "average": 35.0, "delta": -5.0 }],
      "mostImproved": [{ "studentId": "...", "studentName": "Fatma SA", "studentCode": "STU-008", "average": 88.0, "delta": 12.5 }],
      "biggestDrop": []
    },
    "chartSeries": {
      "histogram": [{ "bucket": "90-100", "count": 18 }, { "bucket": "80-89", "count": 15 }, { "bucket": "70-79", "count": 8 }, { "bucket": "60-69", "count": 5 }, { "bucket": "0-59", "count": 4 }],
      "byColumn": [{ "label": "Written Exam", "average": 82.8 }, { "label": "Oral Quiz", "average": 81.0 }]
    },
    "needsAttention": [
      { "type": "low_attendance", "message": "4 students absent", "count": 4 },
      { "type": "failing_scores", "message": "4 students below 60%", "count": 4 }
    ],
    "comparison": {
      "previousAvgScore": 78.2, "currentAvgScore": 82.8, "scoreChange": 4.6,
      "previousAttendanceRate": 88.0, "currentAttendanceRate": 92.0, "attendanceChange": 4.0,
      "perColumnDelta": [
        { "key": "written_exam", "label": "Written Exam", "previousAverage": 35.0, "currentAverage": 38.5, "delta": 3.5 }
      ]
    }
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. Session must exist with uploaded scores (at least one StudentSession + Assessment).
Rate Limit: 30 req/min.
Caching: IMemoryCache, 1 minute TTL. Cache key: session_report_{sessionId}. Data is stale for up to 60s after new uploads.
Response Fields: session (info), analytics (11 stats + gradeDistribution), columnBreakdown[] (per-column avg/min/max/passRate), highlights (topStudents/atRisk/mostImproved/biggestDrop — max 5 each), chartSeries (histogram 5 buckets + byColumn), needsAttention[] (max 5 items), comparison (nullable — null if no previous session for this group).
GradeDistribution Keys: "A" (90-100), "B" (80-89), "C" (70-79), "D" (60-69), "F" (below 60). Based on average score percentage.
Errors: 401 (no token), 403 (wrong role), 404 (session not found).

Export Session Report

Download session report as an Excel file.

Request / Response
Response 200 — binary .xlsx file
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="report_e7f8a9b0.xlsx"
Prerequisites: Logged in with Teacher. Session must belong to this teacher with uploaded scores.
Rate Limit: 30 req/min.
Behavior: Generates Excel with attendance, scores per column, grade distribution. File is generated on-the-fly (not cached).
Errors: 401 (no token), 403 (not your session), 404 (session not found).

Export Students

Download all students in a group as Excel with their stats.

Request / Response
Response 200 — binary .xlsx file
Prerequisites: Logged in with Teacher. Group must belong to this teacher.
Rate Limit: 30 req/min.
Behavior: Generates Excel with student names, codes, enrollment dates, attendance rates, average scores. One row per student.
Errors: 401 (no token), 403 (not your group), 404 (group not found).
🏆 Leaderboard 2 endpoints

Group Leaderboard

Rankings for a specific group. Recomputed hourly by Hangfire.

Request / Response
Query: period = daily | weekly | monthly | alltime
Response 200
{
  "data": [
    {
      "rank": 1, "studentId": "d4e5f6a7-...",
      "studentName": "Ali Hassan", "studentCode": "STU-003",
      "weightedScore": 95.5, "avgScore": 92.0, "attendanceRate": 100.0, "participationScore": 10.0,
      "context": {
        "totalStudents": 50, "scoreDifferenceToTop": 0,
        "averageScore": 82.8, "topScore": 95.5,
        "isTopPerformer": true, "rankLabel": "#1"
      }
    },
    {
      "rank": 2, "studentId": "...",
      "studentName": "Sara Mohamed", "studentCode": "STU-001",
      "weightedScore": 91.2, "avgScore": 88.0, "attendanceRate": 95.0, "participationScore": 9.0,
      "context": { "totalStudents": 50, "scoreDifferenceToTop": 4.3, "averageScore": 82.8, "topScore": 95.5, "isTopPerformer": false, "rankLabel": "#2" }
    }
  ],
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher. Group must belong to this teacher. Leaderboard must have been computed (hourly Hangfire job).
Rate Limit: 30 req/min.
Enums:
· period: "daily" | "weekly" | "monthly" | "alltime". Default: "weekly".
Response: Array (not paginated). Sorted by rank ascending. WeightedScore = avgScore * 0.6 + attendanceRate * 0.3 + participationScore * 0.1.
Errors: 401 (no token), 403 (not your group), 404 (group not found).

Teacher-Wide Leaderboard

Combined rankings across all your groups. Paginated.

Request / Response
Query: period, gradeLevel?, page, pageSize
Response 200 — same PaginatedResult<LeaderboardEntryDto> shape
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Query: period (default "alltime"), gradeLevel (optional filter), page (default 1), pageSize (default 20, max 100).
Behavior: Combines rankings across ALL groups owned by this teacher. A student appears once with their best score across groups.
Errors: 401 (no token), 403 (wrong role).
🔔 Notifications 6 endpoints

List Notifications

Your notifications: badge awards, payment confirmations, system alerts.

Request / Response
Response 200
{
  "data": {
    "items": [
      {
        "id": "...", "userId": "b0000000-...",
        "title": "Payment Confirmed", "body": "Sara Mohamed's payment of 1500 EGP confirmed",
        "type": "PaymentConfirmed", "isRead": false,
        "payload": null, "sentAt": "2026-09-12T10:00:00Z"
      }
    ],
    "page": 1, "pageSize": 20, "totalCount": 12
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role.
Rate Limit: 30 req/min.
Query: page (default 1), pageSize (default 20, max 100).
Response Fields: id, userId, title, body, type (string like "PaymentConfirmed"), isRead (bool), payload (nullable JSON string), sentAt (nullable DateTime).
Errors: 401 (no token), 403 (wrong role).

Unread Count

Quick badge count for the notification bell.

Request / Response
Response 200
{ "data": 3, "meta": { "timestamp": "...", "requestId": "..." } }

Mark Read / Read All

Mark a single notification as read, or mark all as read.

Request / Response
Both return empty object on success
{ "data": {}, "meta": { "timestamp": "...", "requestId": "..." } }
Prerequisites: Logged in with Teacher role. For mark-read: notification must belong to this user.
Rate Limit: 30 req/min.
Behavior: mark-read sets isRead=true on one notification. read-all sets isRead=true on ALL unread notifications for this user.
Errors: 401 (no token), 403 (wrong role), 404 (notification not found for mark-read).

Send Notification to Parent

Send a notification to a specific student's parent.

Request / Response
Request Body
{
  "studentId": "d4e5f6a7-89ab-cdef-0123-456789abcdef",
  "title": "Exam Results",
  "body": "Sara scored 90% in the Cell Division quiz",
  "type": 0
}
Response 201 — No body
Prerequisites: Logged in with Teacher role. Student must exist and have a linked parent (Student.ParentId != null).
Rate Limit: 30 req/min.
Enums:
· type: 0=NewGrade, 1=RankChange, 2=BadgeEarned, 3=PaymentConfirmed, 4=SystemAlert. Integer (NotificationType enum).
Behavior: Creates a single Notification record for the student's parent. Bilingual EN/AR — title and body are stored as-is (caller provides translated version if needed).
Errors: 400 (validation), 401 (no token), 403 (wrong role), 404 (student not found or student has no parent).

Bulk Notify Parents

Send a notification to all parents in a group. Returns count of notifications sent.

Request / Response
Request Body
{
  "title": "Exam Results Available",
  "body": "The results for the Cell Division quiz are now available. Class average: 82.8%",
  "type": 0
}
Response 200
{ "data": 15, "meta": { "timestamp": "...", "requestId": "..." } }
Prerequisites: Logged in with Teacher role. Group must exist and belong to this teacher. Students in the group must have linked parents.
Rate Limit: 30 req/min.
Enums:
· type: 0=NewGrade, 1=RankChange, 2=BadgeEarned, 3=PaymentConfirmed, 4=SystemAlert.
Behavior: Creates one Notification per parent (not per student). If a parent has multiple children in the group, they still get only one notification. Response data = count of notifications sent.
Errors: 400 (validation), 401 (no token), 403 (wrong role), 404 (group not found).
📚 Subjects 1 endpoint

Look Up Subject by Name

Find a subject's ID by its English or Arabic name. Useful for creating groups.

Request / Response
Response 200
{
  "data": {
    "id": "a1b2c3d4-5678-9abc-def0-1234567890ab",
    "name": "Biology",
    "nameArabic": "أحياء",
    "isActive": true,
    "createdAt": "2026-01-01T00:00:00Z",
    "updatedAt": null
  },
  "meta": { "timestamp": "...", "requestId": "..." }
}
Prerequisites: Logged in with Teacher role. Subject must exist.
Rate Limit: 30 req/min.
Behavior: Case-insensitive name search. Use the returned id as subjectId when creating groups (POST /teachers/groups).
Related Endpoints: GET /subjects (list all, paginated), POST /subjects (admin only, create new).
Errors: 401 (no token), 403 (wrong role), 404 (subject not found by name).
Session Excel Lifecycle (NEW)
End-to-end flow: Teacher creates a session with custom Excel columns, fills scores via Excel upload, system processes and generates a report.
Create Session POST /groups/{id}/sessions Configure Headers PUT /sessions/{id}/headers Written Exam (Score, max=50) Download Template GET /upload/template Students + custom columns Upload Excel POST /upload?strategy= Hangfire Job ProcessExcelJob ParseSession + Process StudentSessions Created + Assessments per column Attended, scores, comments Report Generated GET /report Option B analytics Hourly Job Leaderboard Computation Leaderboard Rank + Score per student Daily Job Badge Awarding 4 Badge Types Week/Attendance/Top/Improver Upload Strategies error Reject if scores exist (409) replace Delete old, create fresh skip Keep scored students, add new All create new StudentSession IDs Cache Strategy Report: 1 min TTL Dashboard: 2 min TTL
Registration → Scores → Leaderboard
Register POST /register Verify Enroll StudentsGroups Template GET /template Upload Excel Hangfire Job ProcessExcel Session + Assessments Hourly Job Leaderboard Leaderboard Updated Daily Job BadgeAwarding 4 Badge Types Notification
Payment Lifecycle
Parent Creates Pending POST /payments ? Teacher Reviews PUT /confirm Confirmed Disputed
File Lifecycle
Upload Excel Save to disk uploads/{yyyy}/{MM}/{dd}/ ProcessExcelJob EPPlus UploadLog OK CleanupOldFilesJob > 30 days
Badge Awarding Pipeline
4 Badge Types
StudentOfWeek — Rank 1 in weekly leaderboard
PerfectAttendance — 100% attendance for month
TopScorer — Highest score in a session
ConsistentImprover — Score improved 3+ sessions
Evaluation Flow
1. BadgeAwardingJob runs daily via Hangfire
2. Evaluates each badge type per student
3. Idempotent: checks if already awarded in period
4. Creates StudentsBadges record + notification
Notification System
Teacher Sends POST /send Notification Created Bilingual EN/AR Parent Receives Batch Notify POST /groups/{id}/notify-parents Creates N notifications (one per parent)
JWT Authentication Lifecycle
Login Tokens Issued Access: 15min Refresh: 7 days Access Token Bearer + JWT API Calls Validated Expired POST /refresh-token → new pair 401 → Login again
Hangfire Job Pipeline
Hangfire uploads (5w) scheduled (5w) default (5w) ProcessExcel Leaderboard BadgeAwarding SQL Server Hangfire state Dashboard /hangfire Retry 3x exp. backoff
Known Issues
CriticalSession Upload Concurrency Bug (FIXED)
DbUpdateConcurrencyException on replace strategy. Root cause: tracker pollution from GetByIdWithHeadersAsync loading StudentSessions+Assessments. Fixed: ChangeTracker.Clear() before delete, existingCount-based condition, re-load entities after delete.
CriticalUploadLog Not Updated After Replace (FIXED)
ChangeTracker.Clear() inside replace block detached uploadLog entity. Final SaveChangesAsync had nothing to commit. Fixed: re-upload, re-session, re-headers after replace block.
MediumInvalid Login Returns 400 Instead of 401
POST /auth/login returns 400 for bad credentials. Clients cannot distinguish validation errors from auth failures.
MediumMissing Resource Returns 400 Instead of 404
GET /teachers/groups/{unknown-id} returns 400. Clients cannot distinguish validation from not-found.
MediumGradeLevel Enum Starts at 1, Docs Say 0
SecondaryOne=1, but validation rejects 0. Documentation and behavior disagree on valid range.
LowPhoneNumber Required on Profile Updates
PUT /profile for all 3 roles requires PhoneNumber though docs mark it optional.
LowTest Users 401 in Production
Seeded credentials do not work on live server. Only dev quick-login works.
LowGET /subjects Requires Auth
GET /subjects is reference data but returns 401 without a token.
LowArabic Text Encoding Garbled
nameArabic values come back corrupted — DB collation or response encoding issue.
Architecture Gaps
MediumNo CI/CD Pipeline
.github/workflows/ is empty. No automated build/test/deploy.
MediumNo FCM Push Notifications
FcmToken field exists but no Firebase Cloud Messaging integration.
LowNo Parent Self-Linking
Parent-to-child linking is only via database FK. No QR code flow.
LowNo Avatar/Image Upload
No profile image capability in API or Flutter spec.
LowNo PDF Report Generation
Spec describes QuestPDF but not implemented. Only Excel export.
LowEmpty Improvement Roadmap
docs/Improvement-Roadmap.md has 0 lines.
Rate Limits
Auth Endpoints
10
requests/min
Teacher Endpoints
30
req/min (5 for upload)
Student/Parent
30
requests/min
Admin Endpoints
20
requests/min
Payment Endpoints
20
requests/min
Cache TTL
2m
dashboards, 1m leaderboard
4-Layer Clean Architecture
Dependencies flow INWARD only. Domain has zero dependencies. Every layer depends only on inner layers.
API Layer Infrastructure Layer Application Layer Domain Layer 18 entities · 11 enums · 5 exceptions Zero dependencies — the core of the system Entities User, Teacher, Student, Parent Group, Session, Assessment... Exceptions NotFoundException (404) BadRequestException (400) ConflictException (409) CQRS Use Cases 88 Command/Query + Handler + FluentValidation Pipeline Behaviors LoggingBehavior ValidationBehavior 13 Repository Interfaces IUserRepository... 11 Service Interfaces ICacheService... Implementations 13 Repositories EF Core + SQL Server AsNoTracking for reads No SaveChanges in repos AppDbContext 17 DbSets TPT inheritance Guid.CreateVersion7() UnitOfWork Explicit SaveChanges Concurrency handling SqlException mapping Auth + Email JwtService + BCrypt SmtpEmailService ConsoleEmailService 4 Hangfire Jobs ProcessExcel · Leaderboard BadgeAwarding · Cleanup File + Cache + Metrics LocalFileStorageService CacheService · ApiMetrics API Components 8 Controllers · 95 endpoints Auth · Teacher · Student Parent · Payment · Admin Subject · Dev 4 Middleware SecurityHeaders Localization (?lang=ar) RequestLogging RequestId GlobalExceptionHandler 25+ exception types → RFC 9457 ProblemDetails Rate Limiters (7) Auth=10, Teacher/Student=30 Payment/Admin=20 CORS · Health · Serilog FlutterApp policy /health + /health/ready OpenAPI + Scalar Auto-generated docs External Services SQL Server (Database) SMTP (Email) Seq (Logging) Hangfire (Background Jobs) Domain implements API Infrastructure Application Domain External
Why This Architecture?
Clean Architecture
Why: Domain logic never depends on infrastructure. You can swap SQL Server for PostgreSQL, or SMTP for SendGrid, without changing a single handler.
Trade-off: More files, more boilerplate. A simple CRUD needs 4+ files instead of 1.
CQRS via MediatR
Why: Separates read and write logic. Each use case is a single, testable class. No "god controllers".
Trade-off: MediatR adds indirection. Finding "what happens when I call this endpoint" requires tracing through pipeline → handler.
Repository Pattern
Why: Testability — mock repositories in unit tests. Swap EF Core for Dapper without changing handlers.
Trade-off: Thin repositories in this project (just EF calls). Some argue this adds unnecessary abstraction layers.
Unit of Work
Why: Handlers call SaveChanges explicitly — no accidental saves. Transaction control is in the handler, not hidden in repos.
Trade-off: Easy to forget SaveChangesAsync. No automatic rollback on handler failure (relies on EF Core change tracking).
📁
Project Layout
5 projects in the solution. Dependencies flow inward only.
TrackToImprove.API
Entry point. Controllers, middleware, Program.cs, configuration.
Controllers/ (8 files) Middleware/ (4 files) Configuration/ (4 files) ExceptionHandler/ (1 file) Program.cs (401 lines) appsettings.json appsettings.Development.json
TrackToImprove.Application
Business logic. CQRS handlers, validators, DTOs, interfaces.
UseCases/ (15 domains, 88 files) Behaviors/ (LoggingBehavior, ValidationBehavior) Common/ (CacheKeys, PaginatedResult) DTOs/ (16 subdirectories) Interfaces/ (Repositories/ + Services/) Services/ (LocalizationService)
TrackToImprove.Domain
Core domain. Entities, enums, exceptions, constants. Zero dependencies.
Entities/ (18 files) Enums/ (11 files) Exceptions/ (5 files) Constants/ (AuthConstants)
TrackToImprove.Infrastructure
Data access & external services. EF Core, repos, auth, email, Hangfire.
Persistence/ (DbContext, UnitOfWork) Repositories/ (13 files) Services/ (8 files) Auth/ (JwtService, PasswordHasher) Email/ (SmtpEmailService, ConsoleEmailService) Jobs/ (4 Hangfire jobs) EntityConfigurations/ (18 files) Migrations/ (16 files) SeedData/ (4 SQL files)
Dependency Rules
API ──depends on──▶ Application ──depends on──▶ Domain
Infrastructure ──depends on──▶ Application
✗ Domain NEVER depends on anything
✗ Application NEVER depends on Infrastructure
✗ API NEVER depends on Infrastructure directly (only via Application interfaces)
Key Files to Know
API/Program.cs
All wiring, middleware pipeline, DI
Application/.../DependencyInjection.cs
MediatR + validator registration
Infrastructure/.../DependencyInjection.cs
DbContext + repos + services registration
Infrastructure/Persistence/AppDbContext.cs
17 DbSets, TPT config
Infrastructure/Persistence/UnitOfWork.cs
SaveChanges with error handling
API/ExceptionHandler/GlobalExceptionHandler.cs
25+ exception → HTTP mappings
Request Lifecycle — What Happens When You Call an Endpoint
Click each step to see the code and explanation.
1. Controller Extract JWT, build command 2. MediatR Route to handler 3. LoggingBehavior Log request + timing 4. ValidationBehavior Run FluentValidation 5. Handler Business logic 5a. Check auth/ownership throw ForbiddenException 5b. Call repository Get/Add/Update entity 5c. SaveChangesAsync Via UnitOfWork 5d. Invalidate cache _cache.Remove(key) 5e. Return DTO Never return entity Response flows back: Handler → Controller → OkEnvelope → { data, meta } Exception Paths NotFoundException → 404 BadRequestException → 400 ConflictException → 409 ForbiddenException → 403 ValidationException → 400 + errors[] GlobalExceptionHandler catches all → RFC 9457 ProblemDetails
Example: Creating a Group (Full Code Path)
// 1. Controller (TeacherController.cs) [HttpPost("groups")] [Authorize(Roles = "Teacher")] public async Task<IActionResult> CreateGroupAsync( [FromBody] CreateGroupRequestDto dto, CancellationToken ct) { var teacherId = GetUserId(); // from JWT claims var command = new CreateGroupCommand( dto.Name, dto.AcademicYear, dto.GradeLevel, dto.Period, dto.SubjectId, teacherId, dto.Schedules); var result = await _sender.Send(command, ct); return OkEnvelope(result); // { data, meta } } // 2. Command (CreateGroupCommand.cs) public record CreateGroupCommand( string Name, string AcademicYear, GradeLevel GradeLevel, string Period, Guid SubjectId, Guid TeacherId, List<CreateGroupScheduleRequestDto>? Schedules) : IRequest<GroupResponseDto>; // 3. Handler (CreateGroupCommandHandler.cs) public class CreateGroupCommandHandler : IRequestHandler<CreateGroupCommand, GroupResponseDto> { // Inject: IGroupRepository, ISubjectRepository, // IGroupScheduleRepository, IUnitOfWork, ICacheService public async Task<GroupResponseDto> Handle( CreateGroupCommand request, CancellationToken ct) { var subject = await _subjectRepository .GetByIdAsync(request.SubjectId, ct); if (subject is null) throw new NotFoundException("Subject not found"); var group = new Group { Name = request.Name, TeacherId = request.TeacherId, SubjectId = request.SubjectId, // ... other fields }; await _groupRepository.AddAsync(group, ct); // Process schedules if provided... await _unitOfWork.SaveChangesAsync(ct); _cache.Remove(CacheKeys.TeacherGroups(request.TeacherId)); return new GroupResponseDto(group.Id, group.Name, /* ... */); } } // 4. Validator (CreateGroupCommandValidator.cs) public class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand> { public CreateGroupCommandValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(200); RuleFor(x => x.Period) .Must(p => new[] {"First Term","Second Term"}.Contains(p)); } }
The Pipeline (Automatic for Every Request)
Request enters
LoggingBehavior
ValidationBehavior
Handler executes
Response returned
📚
Domain Entities — The Data Model
18 entities, 11 enums. TPT inheritance: User → Teacher/Student/Parent. Click any entity for details.
TPT Inheritance — Why User is the Base
User (base) Id · Email · FullName · Role · PasswordHash Teacher Student Parent +NationalId · Bio · Rating +StudentCode · ParentId +ConsentGiven · Children
Why TPT (Table-Per-Type)? Each role gets its own table with extra columns. Teachers have NationalId and Bio; Students have StudentCode and ParentId. All share auth fields (email, password) from the User table.
Trade-off vs TPH: TPT requires JOINs for queries (slower reads) but uses less storage and keeps role-specific columns clean. TPH (single table) is faster for reads but wastes space with NULLs.
Enums Reference
UserRole: None=0, Student=1, Parent=2, Teacher=3, Admin=4
GradeLevel: SecondaryOne=1, SecondaryTwo=2, SecondaryThree=3
SessionType: Exam=0, Quiz=1, Homework=2, Oral=3, Attendance=4
SessionColumnType: Attendance=0, Score=1, Comment=2
SubscriptionPlan: Free=0, Pro=1, Premium=2
PaymentStatus: Pending=0, Confirmed=1, Disputed=2
NotificationType: NewGrade=0, RankChange=1, BadgeEarned=2, PaymentConfirmed=3, SystemAlert=4
LeaderboardScope: Group=0, Teacher=1, Platform=2
LeaderboardPeriod: Daily=0, Weekly=1, Monthly=2, AllTime=3
BadgeType: StudentOfWeek=1, PerfectAttendance=4, HighestScore=5, Consistency=6
UploadStatus: Queued=0, Processing=1, Completed=2, Failed=3
+
How to Add a New Feature
Step-by-step guide. Every feature follows the same pattern. Follow these steps in order.
🗃
Database — EF Core + SQL Server
How to add/edit entities, run migrations, and understand the schema.
Adding a New Entity
// Step 1: Create entity in Domain/Entities/ public class Notification { public Guid Id { get; set; } // Guid.CreateVersion7() in handler public Guid UserId { get; set; } public string Title { get; set; } = string.Empty; public string TitleArabic { get; set; } = string.Empty; public string Body { get; set; } = string.Empty; public string BodyArabic { get; set; } = string.Empty; public NotificationType Type { get; set; } public bool IsRead { get; set; } public string? Payload { get; set; } public DateTime SentAt { get; set; } public User User { get; set; } = null!; // Navigation } // Step 2: Add DbSet in AppDbContext.cs public DbSet<Notification> Notifications => Set<Notification>(); // Step 3: Create configuration in EntityConfigurations/ public class NotificationConfiguration : IEntityTypeConfiguration<Notification> { public void Configure(EntityTypeBuilder<Notification> builder) { builder.HasKey(n => n.Id); builder.Property(n => n.Title).HasMaxLength(200).IsRequired(); builder.HasOne(n => n.User).WithMany().HasForeignKey(n => n.UserId); } } // Step 4: Create migration dotnet ef migrations add AddNotificationEntity \ --project src/TrackToImprove.Infrastructure \ --startup-project src/TrackToImprove.API // Step 5: Update database dotnet ef database update \ --project src/TrackToImprove.Infrastructure \ --startup-project src/TrackToImprove.API
Key Database Patterns
IDs
All IDs are Guid.CreateVersion7() — time-ordered UUIDs. Generated in handlers, NOT in entities. Entities have { get; set; } only.
No SaveChanges in Repos
Repositories just call _context.AddAsync() or LINQ queries. Handlers call _unitOfWork.SaveChangesAsync() explicitly.
AsNoTracking for Reads
Read-only queries use .AsNoTracking() — faster, no change tracking overhead. Only use tracking for update scenarios.
Migrations
Always create migrations from CLI, not from code. Project: TrackToImprove.Infrastructure. Startup: TrackToImprove.API.
Entity Relationships
User 1──1 Teacher / Student / Parent (TPT inheritance)
Teacher 1──N Group (teacher owns groups)
Group 1──N Session (group has sessions)
Group 1──N GroupSchedule (weekly schedule)
Group M──M Student via StudentsGroups (join table)
Session 1──N StudentSession (attendance per student)
StudentSession 1──N Assessment (scores per session)
Student M──M Badge via StudentsBadges
Student 1──N Leaderboard (rankings)
Teacher 1──N PaymentRecord
Testing — 672 Tests, 0 Failures
xUnit + Moq + FluentAssertions. Unit tests for handlers, integration tests for controllers.
Unit Tests
// Arrange var repo = new Mock<IGroupRepository>(); var unitOfWork = new Mock<IUnitOfWork>(); var cache = new Mock<ICacheService>(); var handler = new CreateGroupCommandHandler( repo.Object, unitOfWork.Object, cache.Object); var command = new CreateGroupCommand( "Math Group", "2026", GradeLevel.SecondaryOne, "First Term", Guid.NewGuid(), Guid.NewGuid(), null); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Math Group"); repo.Verify(r => r.AddAsync(It.IsAny<Group>(), CancellationToken.None), Times.Once); unitOfWork.Verify(u => u.SaveChangesAsync( CancellationToken.None), Times.Once);
Integration Tests
// Uses CustomWebApplicationFactory // InMemory DB + Fake services public class TeacherControllerTests : IClassFixture<CustomWebApplicationFactory> { [Fact] public async Task GetGroups_ReturnsOk() { // Arrange var client = _factory .WithWebHostBuilder(builder => { /* ... */ }) .CreateClient(); var token = TestAuthHelper .GenerateToken(userId, "Teacher"); client.DefaultRequestHeaders.Authorization = new("Bearer", token); // Act var response = await client .GetAsync("/api/v1/teachers/groups"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); } }
Test Helpers
TestDataBuilder
Static factory methods for creating valid test entities. Thread-safe counter for unique values.
TestAuthHelper
Generates JWT tokens for test users with known secret. Used in integration tests.
DatabaseSeeder
Seeds deterministic test data with fixed GUIDs. Same data every test run.
CustomWebApplicationFactory
InMemory EF Core + FakeEmailService + FakeJobService + FakeFileStorage. Sets ASPNETCORE_ENVIRONMENT=Testing.
FakeServices
FakePasswordHasher (no BCrypt), FakeEmailService (logs to console), FakeJobService (no Hangfire), FakeFileStorage (no disk).
NonSeekableStream
Wrapper for testing LoggingBehavior with non-seekable streams (prevents Stream.ReadTimeout).
Running Tests
# Run all tests dotnet test # Run specific test class dotnet test --filter "FullyQualifiedName~CreateGroupCommandHandlerTests" # Run with verbosity dotnet test --verbosity normal # Run and generate coverage report dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
Configuration — What to Change and Where
Every config file and what it controls.
appsettings.json (base config)
{ "Jwt": { "Issuer": "TrackToImprove", "Audience": "TrackToImprove", "Secret": "SET VIA USER-SECRETS OR ENV VARS — NEVER COMMIT" }, "ConnectionStrings": { "DefaultConnection": "Server=...;Database=TrackToImprove;" }, "Serilog": { "MinimumLevel": "Information" }, "FileStorage": { "BasePath": "uploads", "RetentionDays": 30, "MaxFileSizeMb": 10 }, "Smtp": { "Host": "smtp.gmail.com", "Port": 587, "From": "noreply@track2improve.com" } }
appsettings.Development.json (local overrides)
{ "Jwt": { "Secret": "dev-secret-key-change-in-production" }, "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=TrackToImprove;" }, "BypassEmailVerification": true, "DevAdmin": { "Email": "admin@track2improve.com", "Password": "Admin@t2i@123" } }
Environment Variables (Production)
# JWT Secret Jwt__Secret=your-production-secret # Database ConnectionStrings__DefaultConnection=Server=...;Database=...; # SMTP Smtp__Host=smtp.gmail.com Smtp__Password=your-app-password # Seq Logging Serilog__WriteTo__2__Args__serverUrl=http://seq:8081 # CORS Origins Cors__Origins=https://yourdomain.com
Trade-offs — Why This and Not That
Every architectural decision has a cost. Here's why we chose what we chose.
Common Pitfalls — What Will Bite You
Mistakes that look right but are wrong. Read these before writing any code.