API Reference

AdaptiveTest v0.4 · All endpoints with sample calls · Standard / Adaptive 2-session / Custom Test templates

Auth roles:nonereviewermanageradmin· reviewer/manager also need grade assignment

0. Configuration

All endpoints are served from the Next.js app. Configure via .env.local:

env
# ── Authentication ────────────────────────────────────────────────
GOOGLE_CLIENT_ID=310180611458-xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
NEXTAUTH_SECRET=<random-32-byte-base64>
NEXTAUTH_URL=http://localhost:3000        # change for production

# First user to sign in gets admin role; or set explicitly:
ADMIN_EMAIL=admin@yourschool.com

# ── Data provider ──────────────────────────────────────────────────
DATA_PROVIDER=local                       # "local" | "api"

# Only needed when DATA_PROVIDER=api:
# EXTERNAL_API_BASE_URL=https://your-api.example.com/v1
# EXTERNAL_API_KEY=your-secret-key
# EXTERNAL_API_TIMEOUT_MS=10000

Internal API routes use session cookies (set automatically after Google OAuth login). The optional external data API uses an X-API-Key header — see Section 10.

Base URL (local dev): http://localhost:3000 — Replace with your production domain before deploying.

Student Test Lifecycle

Every student-facing action maps to an internal API route. When DATA_PROVIDER=api those routes transparently proxy to your external service — your frontend never changes.

Important: The entire grade question bank is loaded in one call when the student opens the test page. The adaptive engine then selects and sequences questions client-side. No per-question API calls are made during the test.

EventInternal API calledExternal (DATA_PROVIDER=api)
Student opens /test/5 (full bank, 1 call)GET /api/questions?grade=5GET /questions?grade=5
Student submits testPOST /api/sessionsPUT /sessions/{id}
Webhook fires (if configured)— (server fires automatically)POST <WEBHOOK_URL>
Student / admin views reportGET /api/sessions/{id}GET /sessions/{id}
Download PDF reportGET /api/report/{id}/pdfGET /sessions/{id} (to read session data)

Webhook payload (auto-fired by POST /api/sessions)

When the student submits a test and a webhookUrl is provided in the POST body, the server immediately fires a POST to that URL with the full session payload. This is how your LMS receives live test results without polling.

json
// POST <webhookUrl>   (fired server-side after session is saved)
// Headers:
//   Content-Type: application/json
//   X-Webhook-Secret: <WEBHOOK_SECRET env var, if set>

{
  "event": "test.completed",
  "sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
  "grade": 5,
  "timestamp": "2025-05-03T14:32:08.421Z",
  "startedAt": "2025-05-03T14:12:00.000Z",
  "completedAt": "2025-05-03T14:32:08.421Z",
  "reportUrl": "https://app.example.com/report/a3f82c1d-...",
  "annotations": { "studentId": "S-1042" },
  "result": {
    "score": 620,
    "correctAnswers": 15,
    "totalQuestions": 20,
    "accuracy": 75,
    "timeTaken": 1143,
    "topicBreakdown": [
      { "topic": "Fractions & Decimals", "correct": 7, "total": 8, "percentage": 88 }
    ],
    "difficultyBreakdown": [
      { "difficulty": 1, "correct": 4, "total": 4 },
      { "difficulty": 2, "correct": 5, "total": 6 },
      { "difficulty": 3, "correct": 3, "total": 5 },
      { "difficulty": 4, "correct": 3, "total": 5 }
    ],
    "answers": [
      { "questionId": "q_g5_001", "selected": "C", "correct": "C", "isCorrect": true, "timeTaken": 48 }
    ]
  }
}
Tip: The same payload shape is available via GET /api/sessions/{id}?payload=1 so you can inspect or replay it without re-running a test.

Scoring

The final score field in every result/webhook payload is a weighted 200–800 number. Each correct answer earns difficulty-many points:

DifficultyLabelPoints (per correct)
L1Easy1
L2Easy-Medium2
L3Medium3
L4Medium-Hard4
L5Hard5

Formula:

text
maxPoints    = sum over questions of  difficulty
earnedPoints = sum over correct answers of  difficulty
score        = round(earnedPoints / maxPoints × 600 + 200)
score is clamped to [200, 800]; an unanswered test scores 200.

Example: a 20-question test with the standard quota (L1=4, L2=3, L3=3, L4=4, L5=6) → maxPoints = 4·1 + 3·2 + 3·3 + 4·4 + 6·5 = 65. A student who gets every L4/L5 question correct and nothing else earns 16+30=46 points → score 200 + round(46/65 × 600) = 624.

Adaptive (2-session) bands use the same weighted percentage — a Session-1 score of “80%” means earnedPoints / maxPoints ≥ 0.8, not just 80% of questions correct.

Test Modes Overview

The app supports three test modes. The endpoints below are the same across all three; what differs is how the question list is constructed and how the session progresses.

ModeDescriptionStudent URL
Standard (default)One session of 10–40 questions. Difficulty is distributed via DIFFICULTY_QUOTA — L5 30%, L4 20%, L1/L2/L3 ≈16.7% each. Duration 15–70 min./test/{grade}
Adaptive (2-session)Fixed 70-min / 40-question split: Session 1 (20 Q, same quota as Standard) then Session 2 (20 Q) whose difficulty mix is chosen by the student's Session 1 weighted score band (90–100% / 80–90% / 70–80% / 60–70% / <60%). Same single-session score saved at the end. Started from the home page topic-picker modal./test/{grade}?mode=adaptive
Custom Test (admin-built)Reusable shareable link. Admin defines the difficulty distribution (and for adaptive templates, the per-band Session 2 distribution); the server randomly generates the question list once and locks it. Every student who opens the link sees the same Session 1 and the same Session 2 set for their band. See § 9 Custom Tests (Admin)./t/{templateId}

In-test UX features (all modes)

  • Time-remaining alerts appear top-right (left of the navigation panel on large screens, never covering question content): a one-time 5-second flash at 10 minutes remaining (for tests > 30 min) and 5 minutes remaining (any duration) with an audible two-tone beep; persistent MM:SS countdown for the last 3 minutes.
  • Full-screen annotation overlay — pencil button in the question toolbar opens a drawing canvas (pen, highlighter, text, shapes, eraser, undo, clear) that covers the whole viewport except the question-navigation sidebar. Drawings persist per question and are stored on the session record.
  • Question Navigation panel on the right (large screens) / mobile menu button (small screens) — jump back to any visited question, see which are answered / marked / unanswered.

1. Test Questions

The entire grade question bank is fetched in a single API call the moment a student opens the test page — not one question at a time. For Grade 5 this is typically ~120 questions returned in one response.

After the full bank arrives, the adaptive engine (initTestState()) selects 10–40 questions client-side based on the chosen duration and difficulty distribution. No further API calls are made per question during the test.

API call count per test session:
  • GET /api/questions?grade=N when student opens the test page (full bank)
  • POST /api/sessions when student submits (saves results)
When DATA_PROVIDER=api, the first call proxies to your external GET /questions?grade=N.
GET/api/questions?grade=5
none

Returns the full question bank for a grade in one response. The adaptive engine then selects and sequences 10–40 questions client-side. No auth required — the /test/* pages are protected at the middleware level.

When DATA_PROVIDER=api this proxies to your external GET /questions?grade=5. Your service must return the full bank — the app never requests individual questions.

Request

javascript
const res = await fetch('http://localhost:3000/api/questions?grade=5');
const questions = await res.json();  // full Question[] for the grade (e.g. ~120 items)

// The adaptive engine then selects 10–40 of these client-side.
// You never need to call this per-question during the test.
console.log(`Loaded ${questions.length} questions for Grade 5`);

Response

json
// 200 OK — full Question[] for the grade (~120 items for Grade 5)
// Truncated sample showing 3 questions across different difficulties:
[
  {
    "id": "q_g5_001",
    "grade": 5,
    "topic": "Fractions & Decimals",
    "subtopic": "Adding Fractions",
    "difficulty": 1,
    "source": "Common Core",
    "type": "multiple-choice",
    "question": "What is 1/2 + 1/4?",
    "options": ["1/2", "3/4", "2/6", "1"],
    "answer": "B",
    "explanation": "Convert to a common denominator: 2/4 + 1/4 = 3/4.",
    "image": ""
  },
  {
    "id": "q_g5_047",
    "grade": 5,
    "topic": "Geometry",
    "subtopic": "Area of Composite Shapes",
    "difficulty": 3,
    "source": "Math Kangaroo",
    "type": "multiple-choice",
    "question": "A rectangle is 8 cm long and 5 cm wide. A square of side 2 cm is cut from one corner. What is the remaining area?",
    "options": ["36 cm²", "38 cm²", "40 cm²", "34 cm²"],
    "answer": "A",
    "explanation": "Rectangle area = 40 cm². Square area = 4 cm². Remaining = 40 − 4 = 36 cm².",
    "image": ""
  },
  {
    "id": "q_g5_103",
    "grade": 5,
    "topic": "Number Theory",
    "subtopic": "Prime Factorization",
    "difficulty": 5,
    "source": "AMC 8",
    "type": "multiple-choice",
    "question": "How many distinct prime factors does 360 have?",
    "options": ["2", "3", "4", "5"],
    "answer": "B",
    "explanation": "360 = 2³ × 3² × 5. Three distinct prime factors: 2, 3, and 5.",
    "image": ""
  }
  // ... ~117 more questions
]

// 400 — grade out of range
{ "error": "Invalid grade — must be 1–10" }

2. Sessions

Created automatically when a student finishes the adaptive test. The test page calls POST /api/sessions, receives a sessionId, and redirects to the report page. When DATA_PROVIDER=api, the saved session is also forwarded to your external service via PUT /sessions/{id} (see Section 10 — External Provider API). If a webhook URL was passed, it fires immediately after saving.

POST/api/sessions
none

Create a new test session. Optionally fires a webhook to an external URL after saving.

Request

javascript
const res = await fetch('http://localhost:3000/api/sessions', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grade: 5,
    startedAt: '2025-05-03T14:12:00.000Z',
    baseUrl: 'http://localhost:3000',
    webhookUrl: 'https://your-server.com/webhook',  // optional
    annotations: { studentId: 'S-1042' },           // optional
    result: {
      score: 620,
      correctAnswers: 15,
      totalQuestions: 20,
      timeTaken: 1143,
      topicBreakdown: [
        { topic: 'Fractions', correct: 7, total: 8, percentage: 88 }
      ],
      difficultyBreakdown: [
        { difficulty: 1, correct: 4, total: 4 },
        { difficulty: 2, correct: 5, total: 6 }
      ],
      answers: [
        { questionId: 'q_g5_001', selected: 'C', correct: 'C', isCorrect: true }
      ]
    }
  })
});
const data = await res.json();
// data.sessionId  → use to build report URL
// data.reportUrl  → redirect student here

Response

json
// 200 OK
{
  "sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
  "reportUrl": "http://localhost:3000/report/a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b"
}

// 500 — body was invalid JSON
{ "error": "Failed to create session" }
GET/api/sessions
admin

List all sessions (summary only — no per-question detail). Requires admin login.

Request

javascript
const res = await fetch('http://localhost:3000/api/sessions', {
  credentials: 'include'   // sends the session cookie
});
const sessions = await res.json();

Response

json
// 200 OK — array of session summaries
[
  {
    "sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
    "grade": 5,
    "timestamp": "2025-05-03T14:32:08.421Z",
    "score": 620,
    "correctAnswers": 15,
    "totalQuestions": 20,
    "reportUrl": "http://localhost:3000/report/a3f82c1d-...",
    "webhookSent": true
  }
]

// 401 — not logged in as admin
{ "error": "Unauthorized" }
GET/api/sessions/{id}
none

Get a single session. Add ?payload=1 to get the webhook-shaped payload instead of raw session data.

Request

javascript
const id = 'a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b';

// Raw session
const raw = await fetch(`http://localhost:3000/api/sessions/${id}`).then(r => r.json());

// Webhook-shaped payload (same format POSTed to your webhook URL)
const payload = await fetch(`http://localhost:3000/api/sessions/${id}?payload=1`).then(r => r.json());

Response

json
// 200 OK — raw StoredSession
{
  "sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
  "grade": 5,
  "timestamp": "2025-05-03T14:32:08.421Z",
  "startedAt": "2025-05-03T14:12:00.000Z",
  "completedAt": "2025-05-03T14:32:08.421Z",
  "webhookSent": true,
  "reportUrl": "http://localhost:3000/report/a3f82c1d-...",
  "annotations": { "studentId": "S-1042" },
  "result": { "score": 620, "correctAnswers": 15, ... }
}

// 404
{ "error": "Session not found" }

3. Reports

The report page at /report/{id} calls GET /api/sessions/{id} to load session data. The PDF route also reads the session — both proxy to GET /sessions/{id} on your external service when DATA_PROVIDER=api.

GET/api/report/{id}/pdf
none

Download a fully-formatted A4 PDF report for a session. The session data is fetched via the active provider (local file or external GET /sessions/{id}). Safe to share directly with students — no login required.

Request

javascript
const id = 'a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b';
const res = await fetch(`http://localhost:3000/api/report/${id}/pdf`);
const blob = await res.blob();

// In browser — trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `math-report-${id.slice(0,8)}.pdf`;
a.click();

Response

json
// 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="math-test-report-grade5-a3f82c1d.pdf"
Content-Length: <bytes>

<binary PDF data>

// 404
{ "error": "Session not found" }

4. Review

Used by teachers (reviewers) and managers to review and edit questions. All endpoints require the user to be logged in AND assigned to the requested grade.

GET/api/review/questions?grade=5
adminreviewermanager

Fetch all questions for a grade together with their current review status.

Request

javascript
const res = await fetch('http://localhost:3000/api/review/questions?grade=5', {
  credentials: 'include'
});
const { questions, reviews } = await res.json();

// questions → Question[]
// reviews   → Record<questionId, QuestionReview>

Response

json
// 200 OK
{
  "questions": [
    {
      "id": "q_g5_001",
      "grade": 5,
      "topic": "Fractions & Decimals",
      "subtopic": "Adding Fractions",
      "difficulty": 2,
      "source": "Common Core",
      "type": "multiple-choice",
      "question": "What is 3/4 + 1/2?",
      "options": ["1", "5/4", "1 1/4", "7/4"],
      "answer": "C",
      "explanation": "Convert 1/2 to 2/4: 3/4 + 2/4 = 5/4 = 1 1/4."
    }
  ],
  "reviews": {
    "q_g5_001": {
      "status": "approved",
      "reviewedBy": "teacher@school.com",
      "reviewedByName": "Jane Smith",
      "reviewedAt": "2025-05-03T10:00:00Z",
      "note": "Correct and clear."
    },
    "q_g5_002": { "status": "pending" }
  }
}

// 403 — not assigned to this grade
{ "error": "Not assigned to this grade" }
POST/api/review/submit
adminreviewermanager

Submit or update a review decision (approve / flag / reset to pending) for one question.

Request

javascript
const res = await fetch('http://localhost:3000/api/review/submit', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    questionId: 'q_g5_001',
    grade: 5,
    status: 'approved',      // "approved" | "flagged" | "pending"
    note: 'Question is clear and answer is correct.'
  })
});
const data = await res.json();  // { ok: true }

Response

json
// 200 OK
{ "ok": true }

// 403
{ "error": "Not assigned to this grade" }
GET/api/review/edit?grade=5
adminreviewermanager

Get the full edit history for a grade — who edited what, when, and what changed.

Request

javascript
const res = await fetch('http://localhost:3000/api/review/edit?grade=5', {
  credentials: 'include'
});
const { editLog } = await res.json();

Response

json
// 200 OK
{
  "editLog": [
    {
      "id": "edit_7f3a2b",
      "questionId": "q_g5_001",
      "grade": 5,
      "editedBy": "teacher@school.com",
      "editedByName": "Jane Smith",
      "editedAt": "2025-05-03T11:30:00Z",
      "changes": [
        { "field": "Question", "before": "What is 3/4 + 1/4?", "after": "What is 3/4 + 1/2?" },
        { "field": "Answer",   "before": "A",                  "after": "C" }
      ],
      "comment": "Fixed the fraction in the question — was 1/4 should be 1/2."
    }
  ]
}
POST/api/review/edit
adminreviewermanager

Save edits to a question. The API automatically diffs the changes and logs them.

Request

javascript
const res = await fetch('http://localhost:3000/api/review/edit', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    questionId: 'q_g5_001',
    grade: 5,
    comment: 'Fixed typo in option C',
    updates: {
      // Only include fields you want to change
      question: 'What is 3/4 + 1/2?',
      answer: 'C',
      explanation: 'Convert 1/2 to 2/4: 3/4 + 2/4 = 5/4 = 1 1/4.',
      options: ['1', '5/4', '1 1/4', '7/4']
      // image, explanation can also be updated
    }
  })
});
const data = await res.json();  // { ok: true, editId: "edit_7f3a2b" }

Response

json
// 200 OK
{ "ok": true, "editId": "edit_7f3a2b" }

// 404 — question not found in that grade
{ "error": "Question not found" }

// 403
{ "error": "Not assigned to this grade" }

5. Manager

GET/api/manager/stats
adminmanager

Per-grade review progress. Managers see only their assigned grades; admins see all 10.

Request

javascript
const res = await fetch('http://localhost:3000/api/manager/stats', {
  credentials: 'include'
});
const { perGrade, totalQuestions, totalApproved,
        totalFlagged, totalPending, allowedGrades } = await res.json();
// allowedGrades: number[] for managers, null for admin (= all grades)

Response

json
// 200 OK
{
  "allowedGrades": [4, 5, 6],
  "totalQuestions": 360,
  "totalApproved": 298,
  "totalFlagged": 22,
  "totalPending": 40,
  "perGrade": [
    {
      "grade": 5,
      "total": 120,
      "approved": 98,
      "flagged": 8,
      "pending": 14,
      "reviewers": [
        {
          "email": "teacher@school.com",
          "name": "Jane Smith",
          "reviewedCount": 106,
          "approvedCount": 98,
          "flaggedCount": 8
        }
      ],
      "assignedReviewers": [
        { "email": "teacher@school.com", "name": "Jane Smith" }
      ]
    }
  ]
}
GET/api/manager/approvals
adminmanager

List grade-level approval decisions. Managers see only their own decisions on assigned grades.

Request

javascript
const res = await fetch('http://localhost:3000/api/manager/approvals', {
  credentials: 'include'
});
const { approvals } = await res.json();

Response

json
// 200 OK
{
  "approvals": [
    {
      "grade": 5,
      "managerEmail": "manager@school.com",
      "managerName": "John Manager",
      "status": "approved",
      "comment": "All 120 questions reviewed and signed off.",
      "decidedAt": "2025-05-03T15:00:00Z"
    },
    {
      "grade": 6,
      "managerEmail": "manager@school.com",
      "managerName": "John Manager",
      "status": "revision_requested",
      "comment": "15 questions still flagged — needs another pass.",
      "decidedAt": "2025-05-03T16:00:00Z"
    }
  ]
}
POST/api/manager/approvals
adminmanager

Submit a grade-level approval or revision request. Manager must be assigned to the grade.

Request

javascript
const res = await fetch('http://localhost:3000/api/manager/approvals', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grade: 5,
    status: 'approved',                        // or "revision_requested"
    comment: 'All 120 questions reviewed.'     // optional
  })
});
const data = await res.json();  // { ok: true }

Response

json
// 200 OK
{ "ok": true }

// 400 — invalid status value
{ "error": "Invalid status" }

// 403 — not assigned to that grade
{ "error": "Not assigned to this grade" }

6. Admin — Questions

GET/api/admin/questions?grade=5
admin

Get all questions for a grade with any overrides merged in.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/questions?grade=5', {
  credentials: 'include'
});
const questions = await res.json();  // Question[]

Response

json
// 200 OK — array of Question objects
[
  {
    "id": "q_g5_001",
    "grade": 5,
    "topic": "Fractions & Decimals",
    "subtopic": "Adding Fractions",
    "difficulty": 2,
    "source": "Common Core",
    "type": "multiple-choice",
    "question": "What is 3/4 + 1/2?",
    "options": ["1", "5/4", "1 1/4", "7/4"],
    "answer": "C",
    "explanation": "Convert 1/2 to 2/4: 3/4 + 2/4 = 5/4 = 1 1/4.",
    "image": ""
  }
]

// 400 — grade out of 1–10 range
{ "error": "Invalid grade" }
GET/api/admin/export?grade=5
admin

Download an Excel workbook with all questions for a grade. Use ?grade=all for every grade.

The workbook includes an Instructions sheet explaining how to re-import the file after editing.

Request

javascript
// Download for grade 5
const res = await fetch('http://localhost:3000/api/admin/export?grade=5', {
  credentials: 'include'
});
const blob = await res.blob();

// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mathiq-questions-grade5.xlsx';
a.click();

// All grades
await fetch('http://localhost:3000/api/admin/export?grade=all', { credentials: 'include' });

Response

json
// 200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="mathiq-questions-grade5.xlsx"

<binary .xlsx file>
POST/api/admin/import
admin

Bulk import questions from an Excel file. Rows with empty id are added; rows with an existing id overwrite that question.

Request

javascript
const file = document.querySelector('input[type=file]').files[0];
const form = new FormData();
form.append('file', file);

const res = await fetch('http://localhost:3000/api/admin/import', {
  method: 'POST',
  credentials: 'include',
  body: form           // do NOT set Content-Type — browser adds multipart boundary
});
const { added, updated, errors } = await res.json();

Response

json
// 200 OK
{
  "added": 5,
  "updated": 2,
  "errors": []
}

// Partial success (some rows failed validation)
{
  "added": 3,
  "updated": 1,
  "errors": [
    "Row 8: answer must be A, B, C, or D — got 'E'",
    "Row 12: grade is required"
  ]
}

7. Admin — Users

GET/api/admin/users
admin

List all registered users.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/users', {
  credentials: 'include'
});
const users = await res.json();  // User[]

Response

json
// 200 OK
[
  {
    "email": "vishal.m@moonpreneur.com",
    "name": "Vishal Malhotra",
    "image": "https://lh3.googleusercontent.com/...",
    "role": "admin",
    "createdAt": "2025-04-01T09:00:00Z",
    "lastLogin": "2025-05-03T14:00:00Z"
  },
  {
    "email": "teacher@school.com",
    "name": "Jane Smith",
    "image": "",
    "role": "reviewer",
    "createdAt": "2025-04-15T10:30:00Z",
    "lastLogin": "2025-05-03T08:00:00Z"
  }
]
PATCH/api/admin/users/{email}
admin

Change a user's role. The email address in the URL path must be URL-encoded.

Request

javascript
const email = 'teacher@school.com';
const res = await fetch(
  `http://localhost:3000/api/admin/users/${encodeURIComponent(email)}`,
  {
    method: 'PATCH',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ role: 'manager' })
    // role: "student" | "reviewer" | "manager" | "admin"
  }
);
const user = await res.json();  // updated User object

Response

json
// 200 OK — updated user
{
  "email": "teacher@school.com",
  "name": "Jane Smith",
  "role": "manager",
  "createdAt": "2025-04-15T10:30:00Z"
}

// 404
{ "error": "User not found" }
DELETE/api/admin/users/{email}
admin

Permanently delete a user account.

Request

javascript
const email = 'teacher@school.com';
const res = await fetch(
  `http://localhost:3000/api/admin/users/${encodeURIComponent(email)}`,
  { method: 'DELETE', credentials: 'include' }
);
const data = await res.json();  // { ok: true }

Response

json
// 200 OK
{ "ok": true }

// 404
{ "error": "User not found" }

8. Admin — Review Management

GET/api/admin/review/stats
admin

Full review statistics across all 10 grades with per-reviewer breakdowns.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/review/stats', {
  credentials: 'include'
});
const { perGrade, totalQuestions, totalApproved,
        totalFlagged, totalPending } = await res.json();

Response

json
// 200 OK
{
  "totalQuestions": 1200,
  "totalApproved": 980,
  "totalFlagged": 72,
  "totalPending": 148,
  "perGrade": [
    {
      "grade": 1,
      "total": 120, "approved": 108, "flagged": 4, "pending": 8,
      "reviewers": [
        { "email": "t1@school.com", "name": "Alice",
          "reviewedCount": 112, "approvedCount": 108, "flaggedCount": 4 }
      ]
    }
  ]
}
GET/api/admin/review/assignments
admin

List all teacher reviewer assignments along with all user accounts (for the assignment UI).

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/review/assignments', {
  credentials: 'include'
});
const { assignments, users } = await res.json();

Response

json
// 200 OK
{
  "assignments": [
    {
      "email": "teacher@school.com",
      "name": "Jane Smith",
      "grades": [4, 5, 6],
      "assignedBy": "admin@school.com",
      "assignedAt": "2025-04-20T09:00:00Z"
    }
  ],
  "users": [ /* User[] — all registered users */ ]
}
POST/api/admin/review/assignments
admin

Assign a teacher to review specific grades. Replaces any existing assignment for that email.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/review/assignments', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'teacher@school.com',
    name: 'Jane Smith',
    grades: [4, 5, 6]
  })
});
const data = await res.json();  // { ok: true }

Response

json
// 200 OK
{ "ok": true }
DELETE/api/admin/review/assignments/{email}
admin

Remove all grade assignments from a teacher reviewer.

Request

javascript
const res = await fetch(
  `http://localhost:3000/api/admin/review/assignments/${encodeURIComponent('teacher@school.com')}`,
  { method: 'DELETE', credentials: 'include' }
);
const data = await res.json();  // { ok: true }

Response

json
// 200 OK
{ "ok": true }
POST/api/admin/review/managers
admin

Assign a manager to oversee specific grades. Same pattern as reviewer assignments.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/review/managers', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'manager@school.com',
    name: 'John Manager',
    grades: [4, 5, 6, 7, 8]
  })
});

Response

json
// 200 OK
{ "ok": true }
GET/api/admin/review/log?grade=5
admin

Get the complete edit log for a grade (admin view — no grade-assignment check).

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/review/log?grade=5', {
  credentials: 'include'
});
const { editLog } = await res.json();

Response

json
// 200 OK — same shape as GET /api/review/edit
{
  "editLog": [
    {
      "id": "edit_7f3a2b",
      "questionId": "q_g5_001",
      "grade": 5,
      "editedBy": "teacher@school.com",
      "editedByName": "Jane Smith",
      "editedAt": "2025-05-03T11:30:00Z",
      "changes": [
        { "field": "Answer", "before": "A", "after": "C" }
      ],
      "comment": "Fixed answer key"
    }
  ]
}

9. Custom Tests (Admin) — Test Templates

Reusable, named test templates an admin builds via /admin/tests. Each template stores either a flat question list (standard) or a Session 1 list plus a question set per score band (adaptive). The locked question lists are baked once at creation/regenerate time and reused for every student who opens the share link.

Concepts

  • Multi-grade pool — a template references one or more grades; the random question pick draws from the combined pool.
  • Two modes:standard stores one standardQuestions list;adaptive storessession1Questions plussession2QuestionsByBand keyed by the five fixed score bands.
  • Score bands for adaptive templates are fixed at 90-100, 80-90, 70-80, 60-70, below-60. Every band must have the same total question count.
  • Storage is file-based under .test-templates/{id}.json on the server. Not yet routed through the external provider — only works with DATA_PROVIDER=local.
  • Each attempt creates a normal session record via the regular POST /api/sessions flow — history, report, PDF, webhook all work the same way.

TemplateConfig (request body shape)

FieldTypeReqDescription
namestringAdmin-chosen display name.
gradesnumber[]One or more grades (1–10). Question pool is the union of these grades.
mode'standard' | 'adaptive'Standard = single bucket; adaptive = Session 1 + per-band Session 2.
durationMinutesnumberTotal test duration. For adaptive, this is shared across both sessions.
standardTargetsDifficultyTargets{ 1:N, 2:N, 3:N, 4:N, 5:N } — how many questions per difficulty. Required when mode=standard.
session1TargetsDifficultyTargetsRequired when mode=adaptive. Same shape as standardTargets.
session2TargetsByBandRecord<AdaptiveSessionBand, DifficultyTargets>Required when mode=adaptive. One entry per band; all bands must sum to the same total.
GET/api/admin/test-templates
admin

List all custom test templates (summary view — no question contents). Sorted by updatedAt descending.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/test-templates', { credentials: 'include' });
const { templates } = await res.json();
// templates: Array<{
//   id, name, mode, grades, durationMinutes, createdAt, updatedAt, createdBy
// }>

Response

json
{
  "templates": [
    {
      "id": "8a1f3c2d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
      "name": "Grade 4 Mid-Year Adaptive",
      "mode": "adaptive",
      "grades": [4],
      "durationMinutes": 70,
      "createdAt": "2026-05-10T14:00:00Z",
      "updatedAt": "2026-05-11T09:42:00Z",
      "createdBy": "admin@school.com"
    }
  ]
}
POST/api/admin/test-templates
admin

Create a new template and immediately generate its question paper. Returns the saved template plus a shortfalls report (e.g. you asked for 8 hard questions but only 5 exist in the selected grades).

Empty shortfalls means every requested question was sourced. Non-empty means you may want to widen the grade pool or lower a target.

Request

javascript
// Adaptive template example
const res = await fetch('http://localhost:3000/api/admin/test-templates', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Grade 4 Mid-Year Adaptive',
    grades: [4],
    mode: 'adaptive',
    durationMinutes: 70,
    session1Targets:        { 1: 4, 2: 3, 3: 3, 4: 4, 5: 6 },
    session2TargetsByBand: {
      '90-100':   { 1: 0, 2: 0, 3: 4, 4: 6,  5: 10 },
      '80-90':    { 1: 0, 2: 0, 3: 6, 4: 6,  5: 8  },
      '70-80':    { 1: 0, 2: 0, 3: 7, 4: 7,  5: 6  },
      '60-70':    { 1: 0, 2: 4, 3: 6, 4: 6,  5: 4  },
      'below-60': { 1: 0, 2: 6, 3: 6, 4: 6,  5: 2  },
    },
  }),
});
const { template, shortfalls } = await res.json();
const shareLink = `${location.origin}/t/${template.id}`;

Response

json
{
  "template": {
    "id": "8a1f3c2d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
    "name": "Grade 4 Mid-Year Adaptive",
    "grades": [4],
    "mode": "adaptive",
    "durationMinutes": 70,
    "session1Targets": { "1": 4, "2": 3, "3": 3, "4": 4, "5": 6 },
    "session2TargetsByBand": { "90-100": { ... }, ... },
    "session1Questions": [ /* 20 Question objects */ ],
    "session2QuestionsByBand": {
      "90-100":   [ /* 20 Q */ ],
      "80-90":    [ /* 20 Q */ ],
      "70-80":    [ /* 20 Q */ ],
      "60-70":    [ /* 20 Q */ ],
      "below-60": [ /* 20 Q */ ]
    },
    "createdAt": "2026-05-11T09:42:00Z",
    "updatedAt": "2026-05-11T09:42:00Z",
    "createdBy": "admin@school.com"
  },
  // Per-bucket shortfall (only present when the pool was too small).
  "shortfalls": {
    "session2": { "below-60": { "5": 1 } }
  }
}
GET/api/admin/test-templates/{id}
admin

Get the full template — config plus locked questions.

Request

javascript
const res = await fetch('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…', { credentials: 'include' });
const { template } = await res.json();

Response

json
// 200 OK — { template: TestTemplate }
// 404 — { "error": "Not found" }
PUT/api/admin/test-templates/{id}
admin

Update an existing template's config (or any field). Question lists are not auto-regenerated; call /generate after changing targets to re-roll. Often used by the admin builder to persist config edits before regenerating.

Request

javascript
await fetch('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…', {
  method: 'PUT',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Renamed Test',
    durationMinutes: 60,
    // ...any other TemplateConfig field
  }),
});

Response

json
// 200 OK — { template: TestTemplate }
// 404 — { "error": "Not found" }
DELETE/api/admin/test-templates/{id}
admin

Delete a template. Existing student links will start returning 404.

Request

javascript
await fetch('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…', {
  method: 'DELETE',
  credentials: 'include',
});

Response

json
// 200 OK — { "ok": true }
// 404 — { "error": "Not found" }
POST/api/admin/test-templates/{id}/generate
admin

Re-roll every question bucket using the template's currently saved config. Any manual swaps are lost. Returns the updated template plus a shortfalls report.

Request

javascript
const res = await fetch(
  'http://localhost:3000/api/admin/test-templates/8a1f3c2d-…/generate',
  { method: 'POST', credentials: 'include' }
);
const { template, shortfalls } = await res.json();

Response

json
// 200 OK — { template: TestTemplate, shortfalls: {...} }
GET/api/admin/test-templates/{id}/swap
admin

List up to 25 candidate replacement questions for a specific question in a specific bucket. Candidates match the same difficulty as the question being replaced, and (for Session 2 buckets) skip anything already in Session 1.

For standard templates use bucket=standard. Returns up to 25 results sorted by no particular order (caller can shuffle).

Request

javascript
// Session 1 swap candidates
const url = new URL('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…/swap');
url.searchParams.set('bucket', 'session1');
url.searchParams.set('questionId', 'g3_293');
const { candidates } = await fetch(url, { credentials: 'include' }).then(r => r.json());

// Session 2 swap candidates (specify band)
url.searchParams.set('bucket', 'session2');
url.searchParams.set('band', '80-90');
url.searchParams.set('questionId', 'g3_141');

Response

json
{
  "candidates": [
    { /* Question */ }, { /* Question */ }, …
  ]
}
POST/api/admin/test-templates/{id}/swap
admin

Replace one question in one bucket. If replacementId is omitted, the server picks a random eligible replacement. The replacement must match the question's difficulty, must not be in the same bucket, and (for Session 2) must not be in Session 1.

Request

javascript
// Explicit replacement
await fetch('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…/swap', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    bucket: { kind: 'session1' },           // or { kind: 'standard' }
                                            // or { kind: 'session2', band: '70-80' }
    questionId:    'g3_293',
    replacementId: 'g3_741',                // omit for random pick
  }),
});

Response

json
// 200 OK
{
  "template":    { /* updated TestTemplate */ },
  "replacement": { /* the Question that was put in */ }
}

// 409 — no eligible replacement at this difficulty
{ "error": "No eligible replacement available at this difficulty" }

// 400 — replacementId doesn't match difficulty / is already used
{ "error": "Replacement must match the same difficulty" }

Public read for the student runner

GET/api/test-templates/{id}
none

Read a template for the student-facing runner. Returns enough to render the test (locked questions per bucket) but omits admin metadata (createdBy, etc.).

The runner at /t/{id} reads this server-side; you generally won't call it directly. Open and use the runner UI to start the test.

Request

javascript
const res = await fetch('http://localhost:3000/api/test-templates/8a1f3c2d-…');
const template = await res.json();

Response

json
{
  "id": "8a1f3c2d-…",
  "name": "Grade 4 Mid-Year Adaptive",
  "grades": [4],
  "mode": "adaptive",
  "durationMinutes": 70,
  "session1Questions":      [ /* 20 Q */ ],
  "session2QuestionsByBand": {
    "90-100":   [ /* 20 Q */ ],
    /* ... */
  }
}

// 404 — { "error": "Not found" }

10. External Data Provider API

When to use this section: Only relevant when you set DATA_PROVIDER=api. These are the endpoints your external service must implement. The app calls them internally — your frontend never calls these directly.

All requests from the app include these headers:

http
X-API-Key: <EXTERNAL_API_KEY>
Content-Type: application/json

Questions

GET/questions?grade=5
gray

Return all questions for a grade (with overrides merged).

Request

javascript
// Called internally by ApiDataProvider — not from your frontend
GET https://your-api.example.com/v1/questions?grade=5
X-API-Key: your-secret-key

Response

json
// 200 OK
{ "questions": [ /* Question[] */ ] }
PUT/questions/override
gray

Upsert a question override. If the question id exists, replace it; otherwise add it.

Request

javascript
PUT https://your-api.example.com/v1/questions/override
X-API-Key: your-secret-key
Content-Type: application/json

{
  "id": "q_g5_001",
  "grade": 5,
  "topic": "Fractions",
  "answer": "C",
  ...full Question object...
}

Response

json
// 200 OK
{ "ok": true }
POST/questions/import
gray

Bulk upsert questions. Empty-id rows get new IDs; existing-id rows are overwritten.

Request

javascript
POST https://your-api.example.com/v1/questions/import
X-API-Key: your-secret-key

{ "rows": [ /* Partial<Question>[] from the uploaded spreadsheet */ ] }

Response

json
// 200 OK
{ "added": 3, "updated": 1, "errors": [] }

Users

POST/users
gray

Create a user. Called on first login when the user doesn't exist yet.

Request

javascript
POST https://your-api.example.com/v1/users
X-API-Key: your-secret-key

{
  "email": "newuser@school.com",
  "name": "New User",
  "image": "https://lh3.googleusercontent.com/...",
  "role": "student"
}

Response

json
// 201 Created
{
  "email": "newuser@school.com",
  "name": "New User",
  "image": "",
  "role": "student",
  "createdAt": "2025-05-03T14:00:00Z"
}
PATCH/users/{email}
gray

Update a user's role or record their last login timestamp.

Request

javascript
// Update role
PATCH https://your-api.example.com/v1/users/teacher%40school.com
X-API-Key: your-secret-key

{ "role": "manager" }

// Or record last login
{ "lastLoginAt": "2025-05-03T14:00:00Z" }

Response

json
// 200 OK — updated User object

Sessions

PUT/sessions/{id}
gray

Create or replace a session. Called every time a test session is saved or updated.

Request

javascript
PUT https://your-api.example.com/v1/sessions/a3f82c1d-...
X-API-Key: your-secret-key

{
  "sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
  "grade": 5,
  "timestamp": "2025-05-03T14:32:08.421Z",
  "startedAt": "2025-05-03T14:12:00.000Z",
  "completedAt": "2025-05-03T14:32:08.421Z",
  "webhookSent": false,
  "reportUrl": "https://app.example.com/report/a3f82c1d-...",
  "result": { ... }
}

Response

json
// 200 OK
{ "ok": true }

Reviews, Edit Log, Assignments, Approvals

MethodEndpointDescription
GET/reviewsAll reviews as Record<questionId, QuestionReview>
PUT/reviews/{questionId}Upsert a review for one question
GET/edit-log?grade=NEdit log entries (optional grade filter)
POST/edit-logAppend a new edit log entry
GET/assignmentsList all reviewer grade assignments
GET/assignments/{email}Get one reviewer's assignment
PUT/assignments/{email}Upsert a reviewer assignment
DELETE/assignments/{email}Remove a reviewer assignment
GET/manager-assignmentsList all manager assignments
PUT/manager-assignments/{email}Upsert a manager assignment
DELETE/manager-assignments/{email}Remove a manager assignment
GET/grade-approvalsList all grade approval records
PUT/grade-approvals/{grade}/{managerEmail}Upsert a grade approval decision

Error Envelope (all external endpoints)

json
// All errors must return this shape with an appropriate HTTP status code:
{ "error": "Human-readable description of what went wrong" }

// Common status codes:
// 400 — invalid input / missing required field
// 404 — resource not found
// 500 — internal server error