eduverse-backend / questionBank_Exam_features_docs.md
amirhamdi's picture
feat(assessment): implement Question Bank and Exam Generator backend
ffd0171
|
Raw
History Blame Contribute Delete
42.4 kB

Question Bank And Exam Generator Frontend API Documentation

Date: 2026-05-04
Backend root: D:\Graduation\backend\last_backend\EduVerse_Backend
Audience: frontend developers implementing instructor question bank and exam generator screens.

1. Current Scope

The current backend scope is instructor authoring only.

Allowed role:

  1. INSTRUCTOR

Blocked roles for these features:

  1. STUDENT
  2. TA
  3. ADMIN

Every endpoint below requires:

Authorization: Bearer <instructor_jwt>
Content-Type: application/json

Multipart upload endpoints use:

Authorization: Bearer <instructor_jwt>
Content-Type: multipart/form-data

The backend also checks course ownership. An instructor can only manage courses assigned through:

course_instructors -> course_sections -> course_id

2. Base URL And Common Conventions

Examples use:

{{baseUrl}}/api

IDs are numeric.

Dates are ISO strings when returned or sent as query filters.

Pagination response shape for exam list APIs:

{
  "data": [],
  "meta": {
    "total": 0,
    "page": 1,
    "limit": 20,
    "totalPages": 0
  }
}

Question bank list response shape:

{
  "data": [],
  "total": 0
}

Common NestJS error shape:

{
  "statusCode": 400,
  "message": "Validation or business error message",
  "error": "Bad Request"
}

For exam-generation shortages, message can be an object:

{
  "statusCode": 400,
  "message": {
    "message": "Insufficient question pool for one or more buckets",
    "shortages": [
      {
        "section": "Part A",
        "chapterId": 2,
        "required": 5,
        "available": 3,
        "questionType": "mcq",
        "difficulty": "easy",
        "bloomLevel": null
      }
    ]
  },
  "error": "Bad Request"
}

3. Enums

Question types:

type QuestionBankType =
  | 'written'
  | 'mcq'
  | 'true_false'
  | 'fill_blanks'
  | 'essay';

Question difficulty:

type QuestionBankDifficulty = 'easy' | 'medium' | 'hard';

Bloom levels:

type BloomLevel =
  | 'remembering'
  | 'understanding'
  | 'applying'
  | 'analyzing'
  | 'evaluating'
  | 'creating';

Question status:

type QuestionBankStatus = 'draft' | 'approved' | 'archived';

Attachment type:

type QuestionAttachmentType = 'image' | 'document' | 'audio' | 'video';

Question group type:

type QuestionGroupType =
  | 'passage'
  | 'case_study'
  | 'image_set'
  | 'multipart'
  | 'other';

Exam draft status:

type ExamDraftStatus =
  | 'open'
  | 'finalized'
  | 'expired'
  | 'cancelled'
  | 'failed';

Exam status:

type ExamStatus = 'draft' | 'published' | 'archived';

Exam mark distribution mode:

type ExamMarkDistributionMode =
  | 'manual'
  | 'weight_normalized'
  | 'equal';

Exam rounding policy:

type ExamRoundingPolicy =
  | 'none'
  | 'nearest_0_25'
  | 'nearest_0_5'
  | 'nearest_1';

Exam section answer policy:

type ExamSectionAnswerPolicy = 'answer_all' | 'answer_any';

Exam group selection mode:

type ExamGroupSelectionMode =
  | 'independent'
  | 'keep_group_together'
  | 'exclude_grouped';

Important: only independent is implemented now. Sending keep_group_together or exclude_grouped returns 400.

Exam export format:

type ExamExportFormat = 'html_doc' | 'docx' | 'pdf';

Important: current export implementation returns HTML content with Microsoft Word MIME for html_doc. docx and pdf are reserved by the enum, but frontend should treat html_doc as the supported format unless backend is extended.

4. Shared Response Shapes

4.1 Course Chapter

type CourseChapter = {
  id: number;
  courseId: number;
  name: string;
  chapterOrder: number;
  isActive: number;
  createdAt: string;
  updatedAt: string;
};

4.2 Question Private Response

This response is for instructors only and includes answer keys.

type QuestionBankPrivateResponse = {
  id: number;
  questionId: number;
  courseId: number;
  chapterId: number;
  questionType: QuestionBankType;
  difficulty: QuestionBankDifficulty;
  bloomLevel: BloomLevel;
  status: QuestionBankStatus;
  questionText: string | null;
  expectedAnswerText: string | null;
  hints: string | null;
  options: QuestionOptionResponse[];
  fillBlanks: FillBlankResponse[];
  attachments: QuestionAttachmentResponse[];
  groups: QuestionGroupSummary[];
};
type QuestionOptionResponse = {
  optionId: number;
  optionText: string;
  isCorrect: boolean;
  optionOrder: number;
};
type FillBlankResponse = {
  blankId: number;
  blankKey: string;
  acceptableAnswer: string;
  isCaseSensitive: boolean;
};
type QuestionAttachmentResponse = {
  attachmentId: number;
  fileId: number;
  attachmentType: QuestionAttachmentType;
  caption: string | null;
  altText: string | null;
  displayOrder: number;
  isPrimary: boolean;
  storagePath: string | null;
  imageUrl?: string | null;
};

Nested question responses normalize attachment fields for frontend use. Direct attachment endpoints return the attachment entity shape below.

type QuestionAttachmentEntityResponse = {
  id: number;
  questionId: number;
  fileId: number;
  attachmentType: QuestionAttachmentType;
  caption: string | null;
  altText: string | null;
  displayOrder: number;
  isPrimary: number; // 0 or 1 in direct attachment endpoint responses
  storagePath: string | null;
  createdBy: number;
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
};
type QuestionGroupSummary = {
  groupItemId: number;
  groupId: number;
  itemOrder: number;
  courseId: number;
  chapterId: number;
  title: string | null;
  sharedPrompt: string | null;
  sharedFileId: number | null;
  groupType: QuestionGroupType;
};

4.3 Question Group Response

Group endpoints return TypeORM-shaped group objects.

type QuestionGroup = {
  id: number;
  courseId: number;
  chapterId: number;
  title: string | null;
  sharedPrompt: string | null;
  sharedFileId: number | null;
  groupType: QuestionGroupType;
  createdBy: number;
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
  items?: QuestionGroupItem[];
};
type QuestionGroupItem = {
  id: number;
  groupId: number;
  questionId: number;
  itemOrder: number;
  createdAt: string;
};

4.4 File Upload Response

type FileResponse = {
  fileId: number;
  fileName: string;
  originalFileName: string;
  fileSize: number;
  mimeType: string;
  folderId?: number;
  uploadedBy: number;
  uploaderName?: string;
  createdAt: string;
  versionCount?: number;
  imageUrl?: string | null;
};

4.5 Exam Response

List/get/save/publish/unpublish/archive exam endpoints return a compact response:

type ExamResponse = {
  id: number;
  courseId: number;
  title: string;
  totalMarks?: number | null;
  status: ExamStatus;
  publishedAt?: string | null;
  archivedAt?: string | null;
  itemCount?: number;
  sectionCount?: number;
};

4.6 Exam Draft Response

Draft endpoints return TypeORM-shaped draft objects.

type ExamDraft = {
  id: number;
  courseId: number;
  title: string;
  generationRequestJson: Record<string, unknown>;
  generatedBy: number;
  seed: string;
  totalMarks: number | null;
  markDistributionMode: ExamMarkDistributionMode;
  roundingPolicy: ExamRoundingPolicy;
  status: ExamDraftStatus;
  finalizedExamId: number | null;
  finalizedBy: number | null;
  finalizedAt: string | null;
  failureReason: string | null;
  expiresAt: string;
  createdAt: string;
  updatedAt: string;
  sections?: ExamDraftSection[];
  items?: ExamDraftItem[];
};
type ExamDraftSection = {
  id: number;
  draftId: number;
  title: string;
  instructions: string | null;
  sectionOrder: number;
  totalMarks: number | null;
  answerPolicy: ExamSectionAnswerPolicy;
  requiredAnswerCount: number | null;
  createdAt: string;
  updatedAt: string;
};
type ExamDraftItem = {
  id: number;
  draftId: number;
  questionId: number;
  draftSectionId: number | null;
  chapterId: number;
  questionType: QuestionBankType;
  difficulty: QuestionBankDifficulty;
  bloomLevel: BloomLevel;
  weight: number;
  weightUnits: number | null;
  marks: number | null;
  itemOrder: number;
  overrideReason: string | null;
  question?: QuestionBankPrivateResponse | Record<string, unknown>;
};

5. Question Bank Feature

5.1 What The Feature Supports

The question bank currently supports:

  1. Instructor-owned course chapters.
  2. Single question creation.
  3. Plain bulk question creation up to 50 questions per request.
  4. Related question groups.
  5. Batch creation of questions inside a group.
  6. Question image upload.
  7. Multiple ordered question attachments with captions and alt text.
  8. Question list filters.
  9. Question answer data for instructor authoring.
  10. Question review/status workflow: draft, approved, archived, restored.
  11. Immutable question versions internally for audit/snapshot support.

Questions must belong to an instructor-owned course and a chapter in that course.

5.2 Question Validation Rules

All question types require:

  1. courseId
  2. chapterId
  3. questionType
  4. difficulty
  5. bloomLevel
  6. At least one of questionText or questionFileId

questionFileId must reference an image file accessible to the instructor. Supported MIME types:

  1. image/jpeg
  2. image/png
  3. image/webp
  4. image/gif

MCQ:

  1. Requires options with at least 2 options.
  2. Requires at least one option with isCorrect: true.
  3. Must not include fillBlanks.

True/False:

  1. Requires exactly 2 options.
  2. Requires exactly one option with isCorrect: true.
  3. Must not include fillBlanks.

Fill blanks:

  1. Requires fillBlanks with at least one item.
  2. blankKey values must be unique case-insensitively.
  3. Must not include options.

Written and essay:

  1. Require expectedAnswerText.
  2. Must not include options.
  3. Must not include fillBlanks.

Update behavior:

  1. Omitting options or fillBlanks preserves existing children when type still needs them.
  2. Sending options or fillBlanks replaces that child collection.
  3. Changing questionType removes incompatible child rows.

6. Question Bank Endpoints

6.1 Create Chapter

POST /api/courses/:courseId/chapters

Required path params:

  1. courseId

Request body:

{
  "name": "Chapter 1",
  "chapterOrder": 1
}

Required fields:

  1. name: string, max 200
  2. chapterOrder: integer >= 1

Response:

CourseChapter

6.2 List Chapters

GET /api/courses/:courseId/chapters

Response:

CourseChapter[]

6.3 Update Chapter

PATCH /api/courses/:courseId/chapters/:chapterId

Request body:

{
  "name": "Chapter 2",
  "chapterOrder": 2,
  "isActive": 1
}

All body fields are optional:

  1. name: string, max 200
  2. chapterOrder: integer >= 1
  3. isActive: integer, usually 1 or 0

Response:

CourseChapter

6.4 Delete Chapter

DELETE /api/courses/:courseId/chapters/:chapterId

Response:

{
  "message": "Chapter deleted successfully"
}

6.5 Upload Question Image

POST /api/question-bank/questions/upload-image
Content-Type: multipart/form-data

Form fields:

  1. image: required binary file.

Supported file types:

  1. JPEG
  2. PNG
  3. WebP
  4. GIF

Response:

FileResponse

Use returned fileId as:

  1. questionFileId for image-based questions.
  2. fileId for question attachments.

6.6 Create Question

POST /api/question-bank/questions

Base request body:

{
  "courseId": 34,
  "chapterId": 2,
  "questionType": "mcq",
  "difficulty": "medium",
  "bloomLevel": "understanding",
  "questionText": "Which HTTP method is idempotent?",
  "questionFileId": 88,
  "expectedAnswerText": null,
  "hints": "Think about safe retries.",
  "status": "draft",
  "options": [
    { "optionText": "GET", "isCorrect": true },
    { "optionText": "POST", "isCorrect": false }
  ],
  "fillBlanks": []
}

Required:

  1. courseId
  2. chapterId
  3. questionType
  4. difficulty
  5. bloomLevel
  6. questionText or questionFileId

Optional:

  1. questionText
  2. questionFileId
  3. expectedAnswerText
  4. hints
  5. status, defaults to draft
  6. options, depending on type
  7. fillBlanks, depending on type

Response:

QuestionBankPrivateResponse

6.7 Create MCQ Example

{
  "courseId": 34,
  "chapterId": 2,
  "questionType": "mcq",
  "difficulty": "easy",
  "bloomLevel": "remembering",
  "questionText": "What does HTML stand for?",
  "options": [
    { "optionText": "HyperText Markup Language", "isCorrect": true },
    { "optionText": "High Transfer Machine Language", "isCorrect": false },
    { "optionText": "Hyper Tool Multi Language", "isCorrect": false }
  ]
}

6.8 Create True/False Example

{
  "courseId": 34,
  "chapterId": 2,
  "questionType": "true_false",
  "difficulty": "easy",
  "bloomLevel": "remembering",
  "questionText": "CSS is used for styling web pages.",
  "options": [
    { "optionText": "True", "isCorrect": true },
    { "optionText": "False", "isCorrect": false }
  ]
}

6.9 Create Fill-Blanks Example

{
  "courseId": 34,
  "chapterId": 2,
  "questionType": "fill_blanks",
  "difficulty": "medium",
  "bloomLevel": "applying",
  "questionText": "The HTTP status code for not found is {{code}}.",
  "fillBlanks": [
    {
      "blankKey": "code",
      "acceptableAnswer": "404",
      "isCaseSensitive": false
    }
  ]
}

6.10 Create Written/Essay Example

{
  "courseId": 34,
  "chapterId": 2,
  "questionType": "essay",
  "difficulty": "hard",
  "bloomLevel": "evaluating",
  "questionText": "Explain tradeoffs between server-side rendering and client-side rendering.",
  "expectedAnswerText": "A complete answer should mention SEO, first paint, interactivity, caching, server cost, and complexity.",
  "hints": "Compare performance and operational concerns."
}

6.11 Bulk Create Questions

POST /api/question-bank/questions/batch

Request body:

{
  "courseId": 34,
  "defaultChapterId": 2,
  "questions": [
    {
      "courseId": 34,
      "chapterId": 2,
      "questionType": "mcq",
      "difficulty": "easy",
      "bloomLevel": "remembering",
      "questionText": "Question 1",
      "options": [
        { "optionText": "A", "isCorrect": true },
        { "optionText": "B", "isCorrect": false }
      ]
    }
  ]
}

Required:

  1. courseId
  2. questions: array, minimum 1, maximum 50

Optional:

  1. defaultChapterId: used for items that do not include chapterId.

Rules:

  1. All questions are created transactionally.
  2. If any question is invalid, the whole batch fails.
  3. Every item must belong to the same course.

Response:

{
  "count": 1,
  "created": [
    {
      "id": 101,
      "questionId": 101,
      "courseId": 34,
      "chapterId": 2,
      "questionType": "mcq",
      "difficulty": "easy",
      "bloomLevel": "remembering",
      "status": "draft",
      "questionText": "Question 1",
      "expectedAnswerText": null,
      "hints": null,
      "options": [],
      "fillBlanks": [],
      "attachments": [],
      "groups": []
    }
  ]
}

6.12 List Questions

GET /api/question-bank/questions

Query params:

Param Type Required Notes
courseId number no Restricts to owned course.
chapterId number no Filter by chapter.
questionType enum no mcq, essay, etc.
difficulty enum no easy, medium, hard.
bloomLevel enum no Bloom enum.
status enum no draft, approved, archived.
search string no Case-insensitive text search.
hasAttachments boolean no true or false.
groupId number no Questions in group.
createdBy number no Creator filter.
page number no Default 1.
limit number no Default 20, max 100.

Response:

{
  "total": 1,
  "data": [
    {
      "id": 101,
      "questionId": 101,
      "courseId": 34,
      "chapterId": 2,
      "questionType": "mcq",
      "difficulty": "easy",
      "bloomLevel": "remembering",
      "status": "approved",
      "questionText": "What does HTML stand for?",
      "expectedAnswerText": null,
      "hints": null,
      "options": [
        {
          "optionId": 1,
          "optionText": "HyperText Markup Language",
          "isCorrect": true,
          "optionOrder": 0
        }
      ],
      "fillBlanks": [],
      "attachments": [],
      "groups": []
    }
  ]
}

6.13 Get Question

GET /api/question-bank/questions/:id

Response:

QuestionBankPrivateResponse

6.14 Update Question

PATCH /api/question-bank/questions/:id

All body fields are optional:

{
  "chapterId": 3,
  "questionType": "mcq",
  "difficulty": "medium",
  "bloomLevel": "applying",
  "questionText": "Updated text",
  "questionFileId": null,
  "expectedAnswerText": null,
  "hints": "Updated hint",
  "status": "draft",
  "options": [
    { "optionText": "A", "isCorrect": true },
    { "optionText": "B", "isCorrect": false }
  ],
  "fillBlanks": []
}

Response:

QuestionBankPrivateResponse

6.15 Delete Question

DELETE /api/question-bank/questions/:id

This archives/soft-deletes the question.

Response:

{
  "message": "Question archived successfully"
}

6.16 Add Existing File As Attachment

POST /api/question-bank/questions/:id/attachments

Request body:

{
  "fileId": 88,
  "attachmentType": "image",
  "caption": "Architecture diagram",
  "altText": "Diagram showing request flow",
  "displayOrder": 0,
  "isPrimary": true
}

Required:

  1. fileId

Optional:

  1. attachmentType, defaults to image
  2. caption, max 500
  3. altText, max 500
  4. displayOrder, integer >= 0
  5. isPrimary

Rules:

  1. File must exist and be owned/shared/public for instructor access.
  2. If attachmentType is image, file MIME must be supported image type.
  3. isPrimary: true unsets primary on other attachments for the same question.

Response:

QuestionAttachmentEntityResponse

6.17 Upload Attachment Image

POST /api/question-bank/questions/:id/attachments/upload-image
Content-Type: multipart/form-data

Form fields:

Field Type Required Notes
image file yes JPEG, PNG, WebP, GIF.
caption string no Max 500.
altText string no Max 500.
displayOrder number no >= 0.
isPrimary boolean no Marks primary image.

Response:

QuestionAttachmentEntityResponse

6.18 Reorder Attachments

PATCH /api/question-bank/questions/:id/attachments/reorder

Request body:

{
  "items": [
    { "attachmentId": 10, "displayOrder": 0 },
    { "attachmentId": 11, "displayOrder": 1 }
  ]
}

Required:

  1. items: non-empty array
  2. items[].attachmentId
  3. items[].displayOrder

Response:

QuestionAttachmentEntityResponse[]

6.19 Update Attachment Metadata

PATCH /api/question-bank/questions/:id/attachments/:attachmentId

Request body:

{
  "caption": "Updated caption",
  "altText": "Updated alt text",
  "displayOrder": 2,
  "isPrimary": false
}

All body fields are optional.

Response:

QuestionAttachmentEntityResponse

6.20 Delete Attachment

DELETE /api/question-bank/questions/:id/attachments/:attachmentId

Response:

{
  "message": "Attachment removed successfully"
}

6.21 Create Question Group

POST /api/question-bank/groups

Request body:

{
  "courseId": 34,
  "chapterId": 2,
  "title": "Read the passage and answer",
  "sharedPrompt": "Read the following text, then answer questions 1-3.",
  "sharedFileId": 90,
  "groupType": "passage"
}

Required:

  1. courseId
  2. chapterId

Optional:

  1. title, max 255
  2. sharedPrompt
  3. sharedFileId
  4. groupType, defaults to other

Response:

QuestionGroup

6.22 List Question Groups

GET /api/question-bank/groups

Query params:

Param Type Required Notes
courseId number no Restricts to owned course.
chapterId number no Filter by chapter.
page number no Default backend behavior uses 1.
limit number no Service clamps to 100.

Response:

{
  "total": 1,
  "data": [
    {
      "id": 20,
      "courseId": 34,
      "chapterId": 2,
      "title": "Read the passage and answer",
      "sharedPrompt": "Read the following text...",
      "sharedFileId": 90,
      "groupType": "passage",
      "createdBy": 5,
      "createdAt": "2026-05-04T00:00:00.000Z",
      "updatedAt": "2026-05-04T00:00:00.000Z",
      "deletedAt": null
    }
  ]
}

6.23 Get Question Group

GET /api/question-bank/groups/:groupId

Response:

QuestionGroup

6.24 Update Question Group

PATCH /api/question-bank/groups/:groupId

Request body:

{
  "title": "Updated group title",
  "sharedPrompt": "Updated prompt",
  "sharedFileId": null,
  "groupType": "case_study"
}

All body fields are optional.

Response:

QuestionGroup

6.25 Delete Question Group

DELETE /api/question-bank/groups/:groupId

Deletes group metadata. It does not delete the questions.

Response:

{
  "message": "Question group deleted successfully"
}

6.26 Batch Create Questions Inside Group

POST /api/question-bank/groups/:groupId/questions/batch

Request body:

{
  "questions": [
    {
      "courseId": 34,
      "chapterId": 2,
      "questionType": "mcq",
      "difficulty": "medium",
      "bloomLevel": "understanding",
      "questionText": "What is the main idea of the passage?",
      "options": [
        { "optionText": "Idea A", "isCorrect": true },
        { "optionText": "Idea B", "isCorrect": false }
      ]
    }
  ]
}

Required:

  1. questions: non-empty array

Rules:

  1. Group course ownership is checked.
  2. Questions are created transactionally.
  3. questionFileId is checked for ownership/shared access and image MIME.
  4. If any question fails, the whole batch fails.

Response:

{
  "group": {
    "id": 20,
    "courseId": 34,
    "chapterId": 2,
    "title": "Read the passage and answer",
    "sharedPrompt": "Read the following text...",
    "sharedFileId": 90,
    "groupType": "passage"
  },
  "questions": [
    {
      "id": 101,
      "questionId": 101,
      "courseId": 34,
      "chapterId": 2,
      "questionType": "mcq",
      "difficulty": "medium",
      "bloomLevel": "understanding",
      "status": "draft",
      "questionText": "What is the main idea of the passage?",
      "expectedAnswerText": null,
      "hints": null,
      "options": [],
      "fillBlanks": [],
      "attachments": [],
      "groups": [
        {
          "groupItemId": 1,
          "groupId": 20,
          "itemOrder": 0,
          "courseId": 34,
          "chapterId": 2,
          "title": "Read the passage and answer",
          "sharedPrompt": "Read the following text...",
          "sharedFileId": 90,
          "groupType": "passage"
        }
      ]
    }
  ]
}

6.27 Reorder Questions Inside Group

PATCH /api/question-bank/groups/:groupId/questions/reorder

Request body:

{
  "items": [
    { "questionId": 101, "itemOrder": 0 },
    { "questionId": 102, "itemOrder": 1 }
  ]
}

Response:

QuestionGroupItem[]

6.28 Question Review And Status Endpoints

Submit for review:

POST /api/question-bank/questions/:id/submit-for-review

Response:

QuestionBankPrivateResponse

Approve:

POST /api/question-bank/questions/:id/approve

Request body:

{
  "comment": "Approved for exam generation"
}

Response:

QuestionBankPrivateResponse

Reject:

POST /api/question-bank/questions/:id/reject

Request body:

{
  "comment": "Needs clearer wording"
}

Response:

QuestionBankPrivateResponse

Archive:

POST /api/question-bank/questions/:id/archive

Request body:

{
  "comment": "Outdated"
}

Response:

QuestionBankPrivateResponse

Restore:

POST /api/question-bank/questions/:id/restore

Request body:

{
  "comment": "Restored after review"
}

Response:

QuestionBankPrivateResponse

Frontend note: only approved questions are eligible for exam generation.

7. Exam Generator Feature

7.1 What The Feature Supports

The exam generator currently supports:

  1. Generating drafts from approved question bank questions.
  2. Flat rule-based generation.
  3. Sectioned generation.
  4. Total marks and per-question marks.
  5. Mark distribution by weights, equal distribution, or manual values.
  6. Rounding policies.
  7. Draft expiration and lifecycle.
  8. Manual draft section create/update/delete/reorder.
  9. Manual draft item add/update/remove/reorder.
  10. Override reasons when adding/replacing questions outside the original generation rules.
  11. Saving a draft idempotently to a final exam.
  12. Publishing, unpublishing, and archiving exams.
  13. Exporting an exam as Word-compatible HTML document content.
  14. Immutable exam item snapshots for saved exams.

7.2 Exam Generation Rules

Generation selects only questions where:

  1. courseId matches the exam course.
  2. Question status is approved.
  3. Chapter matches the rule.
  4. Optional question type matches.
  5. Optional difficulty matches.
  6. Optional Bloom level matches.
  7. Question is not already selected in the same draft generation.

The backend returns a shortage error if it cannot satisfy any bucket.

Drafts expire after 24 hours. Expired drafts cannot be edited or saved.

8. Exam Endpoints

8.1 List Exams

GET /api/exams
GET /api/exams/list

Both routes call the same backend logic.

Query params:

Param Type Required Notes
page number no Default 1.
limit number no Default 20.
courseId number no Restricts to owned course.
status ExamStatus no draft, published, archived.
dateFrom ISO date string no Inclusive created-at lower bound.
dateTo ISO date string no Inclusive created-at upper bound.

Response:

{
  data: ExamResponse[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
}

8.2 List Drafts

GET /api/exams/drafts
GET /api/exams/drafts/list
GET /api/exam-drafts
GET /api/exam-drafts/list

All routes call the same backend logic. Prefer /api/exams/drafts for new frontend work.

Query params:

Param Type Required Notes
page number no Default 1.
limit number no Default 20.
courseId number no Restricts to owned course.
status ExamDraftStatus no open, finalized, expired, cancelled, failed.
dateFrom ISO date string no Inclusive created-at lower bound.
dateTo ISO date string no Inclusive created-at upper bound.

Response:

{
  data: ExamDraft[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
}

8.3 Get Draft

GET /api/exams/drafts/:draftId
GET /api/exam-drafts/:draftId

Prefer /api/exams/drafts/:draftId.

Response:

ExamDraft

8.4 Generate Exam Preview / Draft

POST /api/exams/generate-preview

This endpoint creates an editable draft and returns the selected draft data.

Flat request body:

{
  "courseId": 34,
  "title": "Web Development Midterm",
  "rules": [
    {
      "chapterId": 2,
      "count": 5,
      "weightPerQuestion": 1,
      "questionType": "mcq",
      "difficulty": "easy",
      "bloomLevel": "remembering"
    }
  ],
  "totalMarks": 50,
  "markDistributionMode": "weight_normalized",
  "roundingPolicy": "nearest_0_5",
  "groupSelectionMode": "independent",
  "seed": "optional-seed"
}

Sectioned request body:

{
  "courseId": 34,
  "title": "Web Development Final",
  "sections": [
    {
      "title": "Part A - MCQ",
      "instructions": "Answer all questions.",
      "totalMarks": 20,
      "answerPolicy": "answer_all",
      "rules": [
        {
          "chapterId": 2,
          "count": 10,
          "weightPerQuestion": 1,
          "questionType": "mcq"
        }
      ]
    },
    {
      "title": "Part B - Essay",
      "instructions": "Answer any one question.",
      "totalMarks": 30,
      "answerPolicy": "answer_any",
      "requiredAnswerCount": 1,
      "rules": [
        {
          "chapterId": 3,
          "count": 2,
          "weightPerQuestion": 5,
          "questionType": "essay",
          "difficulty": "hard"
        }
      ]
    }
  ],
  "markDistributionMode": "weight_normalized",
  "roundingPolicy": "nearest_0_5",
  "groupSelectionMode": "independent"
}

Required:

  1. courseId
  2. title
  3. At least one of rules or sections

Flat rule required fields:

  1. chapterId
  2. count
  3. weightPerQuestion

Flat rule optional fields:

  1. questionType
  2. difficulty
  3. bloomLevel

Section required fields:

  1. title
  2. totalMarks
  3. rules

Section optional fields:

  1. instructions
  2. answerPolicy, defaults to backend/entity default answer_all
  3. requiredAnswerCount

Top-level optional fields:

  1. totalMarks
  2. markDistributionMode, default weight_normalized
  3. roundingPolicy, default none
  4. groupSelectionMode, only independent supported
  5. seed

Response:

{
  "draftId": 12,
  "seed": "optional-seed-or-generated-uuid",
  "totalQuestions": 10,
  "totalWeight": 10,
  "totalMarks": 50,
  "sections": [
    {
      "id": 1,
      "draftId": 12,
      "title": "Part A - MCQ",
      "instructions": "Answer all questions.",
      "sectionOrder": 0,
      "totalMarks": 20,
      "answerPolicy": "answer_all",
      "requiredAnswerCount": null
    }
  ],
  "items": [
    {
      "id": 1,
      "draftId": 12,
      "questionId": 101,
      "draftSectionId": 1,
      "chapterId": 2,
      "questionType": "mcq",
      "difficulty": "easy",
      "bloomLevel": "remembering",
      "weight": 1,
      "weightUnits": 1,
      "marks": 2,
      "itemOrder": 0,
      "overrideReason": null
    }
  ]
}

8.5 Create Draft Section

POST /api/exams/drafts/:draftId/sections

Request body:

{
  "title": "Part C",
  "instructions": "Answer any two.",
  "totalMarks": 20,
  "answerPolicy": "answer_any",
  "requiredAnswerCount": 2
}

Required:

  1. title

Optional:

  1. instructions
  2. totalMarks
  3. answerPolicy
  4. requiredAnswerCount

Response:

ExamDraftSection

8.6 Reorder Draft Sections

PATCH /api/exams/drafts/:draftId/sections/reorder

Request body:

{
  "items": [
    { "sectionId": 1, "sectionOrder": 0 },
    { "sectionId": 2, "sectionOrder": 1 }
  ]
}

Response:

ExamDraftSection[]

8.7 Update Draft Section

PATCH /api/exams/drafts/:draftId/sections/:sectionId

Request body:

{
  "title": "Updated Part A",
  "instructions": "Updated instructions.",
  "totalMarks": 25,
  "answerPolicy": "answer_all",
  "requiredAnswerCount": null
}

All body fields are optional.

Response:

ExamDraftSection

8.8 Delete Draft Section

DELETE /api/exams/drafts/:draftId/sections/:sectionId

Response:

{
  "message": "Draft section deleted successfully"
}

8.9 Add Draft Item

POST /api/exams/drafts/:draftId/items

Request body:

{
  "questionId": 101,
  "draftSectionId": 1,
  "weightUnits": 1,
  "marks": 2,
  "overrideReason": "Needed to replace a missing question from the same chapter."
}

Required:

  1. questionId

Optional:

  1. draftSectionId
  2. weightUnits
  3. marks
  4. overrideReason

Rules:

  1. Draft must be open and not expired.
  2. Question must be approved.
  3. Question must belong to the same course as the draft.
  4. If the question does not match original generation constraints, overrideReason is required.

Response:

ExamDraftItem

8.10 Reorder Draft Items

PATCH /api/exams/drafts/:draftId/items/reorder

Request body:

{
  "items": [
    { "itemId": 1, "itemOrder": 0 },
    { "itemId": 2, "itemOrder": 1 }
  ]
}

Required:

  1. items: non-empty array
  2. The payload must include every item in the draft exactly once.

Response:

ExamDraftItem[]

8.11 Update Draft Item

PATCH /api/exams/drafts/:draftId/items/:itemId

Request body:

{
  "replacementQuestionId": 102,
  "weight": 2,
  "weightUnits": 2,
  "marks": 5,
  "draftSectionId": 1,
  "itemOrder": 0,
  "overrideReason": "Instructor intentionally selected a harder equivalent question."
}

All body fields are optional.

Rules:

  1. Draft must be open and not expired.
  2. Replacement question must be approved and same-course.
  3. If replacement does not match original generation constraints, overrideReason is required.

Response:

ExamDraftItem

8.12 Remove Draft Item

DELETE /api/exams/drafts/:draftId/items/:itemId

Rules:

  1. Draft must be open and not expired.
  2. Cannot remove the last item from an open draft.

Response:

{
  "message": "Draft item removed successfully"
}

8.13 Save Draft To Exam

POST /api/exams/drafts/:draftId/save

No request body.

Rules:

  1. Draft must be open and not expired.
  2. Draft must contain at least one item.
  3. Save is idempotent. If already finalized, backend returns the existing exam.
  4. Saved exam gets immutable item snapshots.

Response:

ExamResponse

8.14 Get Exam

GET /api/exams/:id

Response:

ExamResponse

8.15 Publish Exam

POST /api/exams/:id/publish

Request body:

{
  "reason": "Ready for review"
}

Optional:

  1. reason, max 1000

Response:

ExamResponse

8.16 Unpublish Exam

POST /api/exams/:id/unpublish

Request body:

{
  "reason": "Need edits before publishing"
}

Response:

ExamResponse

8.17 Archive Exam

POST /api/exams/:id/archive

Request body:

{
  "reason": "Old version"
}

Response:

ExamResponse

8.18 Export Exam Word

POST /api/exams/:id/export-word

Request body:

{
  "format": "html_doc",
  "includeAnswerKey": false
}

Optional:

  1. format, default/current practical value: html_doc
  2. includeAnswerKey, default false

Response:

{
  "fileName": "exam-1.doc",
  "mimeType": "application/msword",
  "content": "<html><head><meta charset=\"utf-8\"></head><body>...</body></html>"
}

Frontend usage:

  1. Create a Blob from content.
  2. Use mimeType.
  3. Download with fileName.

Example:

const blob = new Blob([response.content], { type: response.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = response.fileName;
a.click();
URL.revokeObjectURL(url);

9. Recommended Frontend Workflows

9.1 Question Authoring Workflow

  1. Load chapters: GET /api/courses/:courseId/chapters.
  2. Optionally upload an image: POST /api/question-bank/questions/upload-image.
  3. Create question: POST /api/question-bank/questions.
  4. Optionally add more attachments:
    • existing file: POST /api/question-bank/questions/:id/attachments
    • upload image: POST /api/question-bank/questions/:id/attachments/upload-image
  5. Preview the returned QuestionBankPrivateResponse.
  6. Approve when ready: POST /api/question-bank/questions/:id/approve.

Frontend should block or warn on invalid question-type combinations before calling the API.

9.2 Bulk Question Authoring Workflow

  1. Build an array of up to 50 questions.
  2. Validate each question locally using the rules in Section 5.2.
  3. Submit once: POST /api/question-bank/questions/batch.
  4. If the request fails, show the backend message and let the instructor fix the batch.

9.3 Related/Grouped Question Workflow

  1. Create group: POST /api/question-bank/groups.
  2. Add grouped questions: POST /api/question-bank/groups/:groupId/questions/batch.
  3. Reorder if needed: PATCH /api/question-bank/groups/:groupId/questions/reorder.
  4. Render group prompt/file at top and questions below using groups in question response.

9.4 Exam Generation Workflow

  1. Ensure enough approved questions exist. List questions by status=approved.
  2. Build generation rules or sections.
  3. Generate draft: POST /api/exams/generate-preview.
  4. Show returned sections/items for instructor review.
  5. Let instructor add, replace, remove, or reorder items.
  6. Save: POST /api/exams/drafts/:draftId/save.
  7. Optionally publish: POST /api/exams/:id/publish.
  8. Export: POST /api/exams/:id/export-word.

9.5 Draft Editing UX Rules

Disable editing when:

  1. status !== 'open'
  2. expiresAt is in the past

Show warnings when:

  1. User tries to remove the last draft item.
  2. User replaces/adds a question outside the original generation rules and no overrideReason is entered.
  3. Reorder payload does not include all items.

10. Frontend Field Reference

10.1 Required Question Form Fields

Always show:

  1. Course selector
  2. Chapter selector
  3. Question type
  4. Difficulty
  5. Bloom level
  6. Question text and/or image upload

Show for MCQ:

  1. Options editor
  2. At least two options
  3. Correct option checkbox on each option

Show for true/false:

  1. Two fixed options: True and False
  2. Exactly one correct option

Show for fill blanks:

  1. Blank key
  2. Acceptable answer
  3. Case-sensitive toggle

Show for written/essay:

  1. Expected answer text
  2. Optional hints

10.2 Attachment Form Fields

  1. File/image
  2. Caption
  3. Alt text
  4. Display order
  5. Primary image toggle

10.3 Exam Generator Form Fields

Flat generator:

  1. Course
  2. Title
  3. Total marks
  4. Mark distribution mode
  5. Rounding policy
  6. Rules: chapter, count, weight per question, optional type/difficulty/Bloom

Sectioned generator:

  1. Course
  2. Title
  3. Sections
  4. Per-section title
  5. Per-section instructions
  6. Per-section total marks
  7. Per-section answer policy
  8. Per-section required answer count when answer policy is answer_any
  9. Per-section rules

11. Important Backend Constraints For Frontend

  1. Only instructor JWTs can use these APIs.
  2. Course ownership is enforced server-side.
  3. Only approved questions are used by exam generation.
  4. Drafts expire after 24 hours.
  5. Draft save creates immutable snapshots, so later question edits do not change saved exams.
  6. Student exam delivery is not implemented in this feature scope.
  7. groupSelectionMode must currently be omitted or set to independent.
  8. Export currently returns document content directly, not a stored file URL.
  9. Question list/get responses expose correct answers because they are instructor-only.

12. Minimal TypeScript Client Types

export type ApiList<T> = {
  data: T[];
  total: number;
};

export type ApiPage<T> = {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
};

export type ApiMessage = {
  message: string;
};

Recommended frontend route modules:

  1. questionBankApi.ts
  2. questionGroupsApi.ts
  3. examGeneratorApi.ts
  4. examDraftsApi.ts
  5. examExportApi.ts

13. Quick Endpoint Index

Question bank chapters:

POST   /api/courses/:courseId/chapters
GET    /api/courses/:courseId/chapters
PATCH  /api/courses/:courseId/chapters/:chapterId
DELETE /api/courses/:courseId/chapters/:chapterId

Question bank questions:

POST   /api/question-bank/questions
POST   /api/question-bank/questions/batch
POST   /api/question-bank/questions/upload-image
GET    /api/question-bank/questions
GET    /api/question-bank/questions/:id
PATCH  /api/question-bank/questions/:id
DELETE /api/question-bank/questions/:id

Question attachments:

POST   /api/question-bank/questions/:id/attachments
POST   /api/question-bank/questions/:id/attachments/upload-image
PATCH  /api/question-bank/questions/:id/attachments/reorder
PATCH  /api/question-bank/questions/:id/attachments/:attachmentId
DELETE /api/question-bank/questions/:id/attachments/:attachmentId

Question groups:

POST   /api/question-bank/groups
GET    /api/question-bank/groups
GET    /api/question-bank/groups/:groupId
PATCH  /api/question-bank/groups/:groupId
DELETE /api/question-bank/groups/:groupId
POST   /api/question-bank/groups/:groupId/questions/batch
PATCH  /api/question-bank/groups/:groupId/questions/reorder

Question review:

POST /api/question-bank/questions/:id/submit-for-review
POST /api/question-bank/questions/:id/approve
POST /api/question-bank/questions/:id/reject
POST /api/question-bank/questions/:id/archive
POST /api/question-bank/questions/:id/restore

Exams and drafts:

GET  /api/exams
GET  /api/exams/list
GET  /api/exams/drafts
GET  /api/exams/drafts/list
GET  /api/exams/drafts/:draftId
GET  /api/exam-drafts
GET  /api/exam-drafts/list
GET  /api/exam-drafts/:draftId
POST /api/exams/generate-preview

Draft sections:

POST   /api/exams/drafts/:draftId/sections
PATCH  /api/exams/drafts/:draftId/sections/reorder
PATCH  /api/exams/drafts/:draftId/sections/:sectionId
DELETE /api/exams/drafts/:draftId/sections/:sectionId

Draft items:

POST   /api/exams/drafts/:draftId/items
PATCH  /api/exams/drafts/:draftId/items/reorder
PATCH  /api/exams/drafts/:draftId/items/:itemId
DELETE /api/exams/drafts/:draftId/items/:itemId

Saved exams:

POST /api/exams/drafts/:draftId/save
GET  /api/exams/:id
POST /api/exams/:id/publish
POST /api/exams/:id/unpublish
POST /api/exams/:id/archive
POST /api/exams/:id/export-word