# Course Scores API Documentation

API endpoints that let an external system (the LMS) **submit** and **read back** annual pursuit scores (السعي السنوي) for students in a course distribution. Submitted scores are stored in `GB_ScoresFromExternalSystems` and later consumed by the Gradebook system: once a score is **approved** in Gradebook it is **frozen** (no further updates accepted), and approved scores seed the generated gradebook's `AnnualPursuitScore`.

> **Terminology** — In this domain: **Course** = المادة الدراسية, **Student Academic Status** (the student's enrollment record for a year/term) = السيرة الدراسية.

## Authentication

Both endpoints require **third-party client credentials** via headers:

| Header | Description |
|-|-|
| `X-Client-ID` | Your client UUID |
| `X-Client-Secret` | Your client secret |
| `Accept` | Must be `application/json` |
| `Content-Type` | `application/json` (for the POST body) |
| `X-Language` | Optional. `en` (default) or `ar` — controls the language of messages |

### Required Permissions

| Endpoint | Permission |
|-|-|
| Upsert Course Scores | `integration@lms:course-score:upsert` |
| List Course Scores | `integration@lms:course-score:list` |

---

## Endpoints

### 1. Upsert Course Scores

```
POST /api/v1/integration/lms/course-scores
```

Creates or updates annual pursuit scores for a set of students **within a single course distribution**. The LMS provides a `student_id` per row; the API resolves each student's current `StudentAcademicStatus` internally and upserts keyed on `(course_distribution_id, resolved student academic status)`: an existing non-deleted record is updated; otherwise a new one is created.

This endpoint is **all-or-nothing**: if any item fails a business rule, the **entire request is rejected** (HTTP 422) and nothing is written. The error response identifies **each** offending student and the specific reason (see [Validation & Errors](#validation--errors)).

#### Request Body

| Field | Type | Required | Description |
|-|-|-|-|
| `user_id` | integer | Yes | The acting user. Must exist in `users`. Used for (a) **authorization** — the user's organization must cover the course distribution's study-program org, and (b) **ownership stamping** — see [Ownership stamping](#ownership-stamping). |
| `course_distribution_id` | integer | Yes | The target course distribution (`SIS_CourseDistributions.Id`, non-deleted). |
| `students` | array | Yes | Non-empty list of per-student score entries. |
| `students[].student_id` | integer | Yes | `SIS_Students.Id` (non-deleted). Must be **unique** within the payload. The API resolves the student's **current** `StudentAcademicStatus` internally (`InfoStatus = current`, within the course distribution's study program, with an active admission) and requires a non-deleted `StudentSelectedCourse` for it. |
| `students[].score` | number | Yes | The annual pursuit score. `0 …` **cap**, where **cap = the course distribution's formative weight** (not a fixed 100). Up to 2 decimal places. |
| `students[].notes` | string | No | Free-text note, max 1000 characters. |

> **Score cap** — The maximum allowed score is the **formative weight** configured for the course distribution (the coursework/السعي portion of the 100-mark scale), derived the same way the Gradebook computes it. For example, if the formative weight is `40`, a score of `41` is rejected with a message stating the real cap (`… between 0 and 40 …`). If a course distribution has no configured formative weight, the cap is `0` and only a score of `0` is accepted.

#### Example Request

```bash
curl -s -X POST 'https://{{host}}/api/v1/integration/lms/course-scores' \
  -H 'X-Client-ID: {{client_id}}' \
  -H 'X-Client-Secret: {{client_secret}}' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "user_id": 111,
    "course_distribution_id": 2953,
    "students": [
      { "student_id": 17913, "score": 38.5, "notes": "ممتاز" },
      { "student_id": 17920, "score": 30 }
    ]
  }'
```

#### Success Response (`200`)

`payload.data` is the list of persisted records; `payload.summary` reports how many were created vs updated.

```json
{
  "status": "success",
  "request_trace_id": "uuid",
  "message": "Course scores saved successfully.",
  "payload": {
    "data": [
      {
        "id": 7,
        "annual_pursuit_score": 38.5,
        "notes": "ممتاز",
        "is_approved": null,
        "approved_by": null,
        "approved_at": null,
        "created_by": 111,
        "last_modified_by": 111,
        "student_id": 17920,
        "student_academic_status_id": 23432,
        "student_academic_status": {
          "id": 23432,
          "student": {
            "id": 17920,
            "name": "...",
            "university_number": 100,
            "university_id_number": "2425334820"
          }
        },
        "course_distribution_id": 2953,
        "course_distribution": {
          "id": 2953,
          "grade": 1,
          "course": { "id": 4126, "code": "Comp", "name": { "ar": "مبادئ الحاسوب 1", "en": "Computer principles 1" } },
          "study_program": { "id": 1140, "code": "LAPTU1", "name": { "ar": "...", "en": "Bachelor of Medical Laboratory Technology" } },
          "academic_year_division": { "id": 5, "name": { "ar": "الفصل الاول", "en": "First Semester" } }
        },
        "student_selected_course_id": 60835,
        "is_deleted": false,
        "deleted_at": null,
        "created_at": "2026-06-17T10:18:49.000000Z",
        "last_modification_date": "2026-06-17T10:18:49.000000Z"
      }
    ],
    "summary": { "total": 2, "created": 1, "updated": 1 }
  }
}
```

#### Ownership stamping

| Operation | `CreatorId` | `LastModifierId` |
|-|-|-|
| **Create** (new record) | set to `user_id` | set to `user_id` |
| **Update** (existing record) | unchanged | set to `user_id` |

#### Frozen records

A record whose `is_approved = true` (approved in Gradebook) is **frozen** — any upsert targeting it is rejected. Disapproving it in Gradebook re-opens it for updates.

---

### 2. List Course Scores

```
GET /api/v1/integration/lms/course-scores
```

Returns a paginated list of the submitted scores, with their full course-distribution and student context. Filters mirror the grouping levels used by the other LMS read endpoints (course, study program, grade, organization) plus student and approval-status filters. Course-level filters (`course_id`, `course_code`, `os_id`, `academic_learning_framework_id`, `study_program_id`, `study_program_code`, `grade`) are resolved to the matching course distributions.

#### Parameters

Parameters may be supplied as query-string parameters or a JSON body.

| Parameter | Type | Required | Default | Description |
|-|-|-|-|-|
| `course_distribution_id` | integer | No | — | Filter by a single course distribution |
| `course_id` | integer | No | — | Filter by course (all its distributions) |
| `course_code` | string (max 64) | No | — | Filter by course code |
| `os_id` | integer | No | — | Filter by the course's organizational structure (**includes all descendants**) |
| `academic_learning_framework_id` | integer | No | — | Filter by the course's academic learning framework system |
| `study_program_id` | integer | No | — | Filter by study program |
| `study_program_code` | string (max 64) | No | — | Filter by study program code |
| `grade` | integer (min 1) | No | — | Filter by academic grade/level |
| `student_id` | integer | No | — | Filter by student (`SIS_Students.Id`, resolved through the student academic status) |
| `student_academic_status_id` | integer | No | — | Filter by a single student academic status |
| `is_approved` | boolean | No | — | Filter by approval status (`true`/`false`) |
| `search` | string (max 255) | No | — | Search in notes and student name / university numbers |
| `sort_by` | string | No | `Id` | One of: `Id`, `AnnualPursuitScore`, `CourseDistributionId`, `StudentAcademicStatusId`, `IsApproved`, `CreationTime`, `LastModificationTime` |
| `sort_direction` | string | No | `asc` | `asc` or `desc` |
| `last_modification_date` | date | No | — | Return records modified on or after this date |
| `is_deleted` | boolean | No | — | Filter by deletion status |
| `per_page` | integer (1-100) | No | 15 | Records per page |
| `page` | integer (min 1) | No | 1 | Page number |

#### Example Request

```bash
curl -s 'https://{{host}}/api/v1/integration/lms/course-scores?course_distribution_id=2953&is_approved=true&per_page=50' \
  -H 'X-Client-ID: {{client_id}}' \
  -H 'X-Client-Secret: {{client_secret}}' \
  -H 'Accept: application/json'
```

#### Success Response (`200`)

```json
{
  "status": "success",
  "request_trace_id": "uuid",
  "message": "Course scores retrieved successfully.",
  "payload": {
    "data": [ { "id": 1, "annual_pursuit_score": 88, "is_approved": true, "...": "... (same item shape as Upsert)" } ],
    "pagination": { "total": 6, "per_page": 50, "current_page": 1, "last_page": 1, "from": 1, "to": 6 }
  }
}
```

---

## Response Item Fields

Both endpoints return the same per-record shape:

| Field | Type | Description |
|-|-|-|
| `id` | integer | Score record id (`GB_ScoresFromExternalSystems.Id`) |
| `annual_pursuit_score` | number | The submitted annual pursuit score |
| `notes` | string/null | Free-text note |
| `is_approved` | boolean/null | `null` = pending, `true` = approved (frozen), `false` = disapproved/reopened |
| `approved_by` | integer/null | User id who approved (set by Gradebook) |
| `approved_at` | datetime/null | Approval timestamp |
| `created_by` | integer/null | `user_id` that created the record |
| `last_modified_by` | integer/null | `user_id` that last modified the record |
| `student_id` | integer/null | The student (`SIS_Students.Id`) the score belongs to |
| `student_academic_status_id` | integer | The student academic status (السيرة الدراسية) |
| `student_academic_status` | object/null | `{ id, student: { id, name, university_number, university_id_number } }` |
| `course_distribution_id` | integer | The course distribution |
| `course_distribution` | object/null | `{ id, grade, course: {id,code,name{ar,en}}, study_program: {id,code,name{ar,en}}, academic_year_division: {id,name{ar,en}} }` |
| `student_selected_course_id` | integer/null | Resolved `SIS_StudentSelectedCourses.Id` for the (course distribution, student academic status) pair |
| `is_deleted` | boolean | Soft-delete flag |
| `deleted_at` | datetime/null | Deletion timestamp |
| `created_at` | datetime | Creation timestamp |
| `last_modification_date` | datetime/null | Last modification timestamp |

---

## Validation & Errors

The request is **all-or-nothing**: if any student fails a check, nothing is written and the response is **HTTP 422** with a structured, per-record error list so the LMS can map each failure back to its payload row.

```json
{
  "status": "error",
  "message": "The given data was invalid.",
  "payload": {
    "errors": [
      {
        "student_id": 17913,
        "student_name": "…",
        "course_name": { "ar": "…", "en": "…" },
        "course_distribution_id": 2953,
        "issue": "not_registered",
        "message": "The student is not registered in this course distribution."
      },
      {
        "student_id": 17920,
        "student_name": "…",
        "course_name": { "ar": "…", "en": "…" },
        "course_distribution_id": 2953,
        "issue": "score_out_of_range",
        "message": "The score must be a number between 0 and 40 with up to two decimal places."
      }
    ]
  }
}
```

Each error object:

| Field | Description |
|-|-|
| `student_id` | The offending student (`null` for request-level problems). |
| `student_name` | `FullName` of the student (`null` when unknown). |
| `course_name` | `{ ar, en }` of the course (`null` when the course distribution is invalid). |
| `course_distribution_id` | The submitted course distribution. |
| `issue` | Machine-readable issue code (see below). |
| `message` | Localized human-readable message (honors `X-Language`). |

**Per-student issue codes**

| `issue` | Meaning |
|-|-|
| `student_not_found` | `student_id` does not exist. |
| `no_current_sas` | The student has no current academic status in this course's study program with an active admission. |
| `not_registered` | The student's current academic status has no `StudentSelectedCourse` for this course distribution. |
| `record_frozen` | The student's score is already approved (frozen) and cannot be modified. |
| `score_out_of_range` | The score exceeds the course distribution's formative cap (message states the real cap). |
| `invalid_payload` | The row is structurally invalid (missing/malformed `student_id`, `score`, …). |

**Request-level issue codes** (returned with `student_id = null`)

| `issue` | Meaning |
|-|-|
| `user_not_found` | `user_id` does not exist. |
| `course_distribution_not_found` | `course_distribution_id` does not exist / is deleted. |
| `course_distribution_not_in_allowed_orgs` | The `user_id` may not act on this course distribution's organization. |

With `X-Language: ar` the `message` fields are returned in Arabic (السيرة الدراسية / المادة الدراسية terminology).

Other statuses: `401` (missing/invalid client credentials or lacking the required permission), `500` (unexpected server error, with a `request_trace_id` for support).

---

## Integration Flow

1. **Submit scores** — `POST /course-scores` per course distribution, in batches of student academic statuses. Fix any per-student errors and resubmit (all-or-nothing).
2. **Read back** — `GET /course-scores` to confirm what was stored (filter by course/program/org/grade or approval status).
3. **Approval (Gradebook side)** — staff approve/disapprove the scores in the Gradebook system. Approved records are **frozen** here (further upserts rejected) and their `annual_pursuit_score` is used when the gradebook is generated.
4. **Re-open** — a disapproved record becomes editable again and can be re-submitted via the upsert endpoint.
