AdaptiveTest v0.4 · All endpoints with sample calls · Standard / Adaptive 2-session / Custom Test templates
All endpoints are served from the Next.js app. Configure via .env.local:
# ── 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.
http://localhost:3000 — Replace with your production domain before deploying.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.
| Event | Internal API called | External (DATA_PROVIDER=api) |
|---|---|---|
| Student opens /test/5 (full bank, 1 call) | GET /api/questions?grade=5 | GET /questions?grade=5 |
| Student submits test | POST /api/sessions | PUT /sessions/{id} |
| Webhook fires (if configured) | — (server fires automatically) | POST <WEBHOOK_URL> |
| Student / admin views report | GET /api/sessions/{id} | GET /sessions/{id} |
| Download PDF report | GET /api/report/{id}/pdf | GET /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.
// 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 }
]
}
}GET /api/sessions/{id}?payload=1 so you can inspect or replay it without re-running a test.The final score field in every result/webhook payload is a weighted 200–800 number. Each correct answer earns difficulty-many points:
| Difficulty | Label | Points (per correct) |
|---|---|---|
| L1 | Easy | 1 |
| L2 | Easy-Medium | 2 |
| L3 | Medium | 3 |
| L4 | Medium-Hard | 4 |
| L5 | Hard | 5 |
Formula:
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.
earnedPoints / maxPoints ≥ 0.8, not just 80% of questions correct.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.
| Mode | Description | Student 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)
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.
1× — GET /api/questions?grade=N when student opens the test page (full bank)1× — POST /api/sessions when student submits (saves results)DATA_PROVIDER=api, the first call proxies to your external GET /questions?grade=N.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.
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
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
// 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" }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.
Create a new test session. Optionally fires a webhook to an external URL after saving.
Request
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 hereResponse
// 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" }List all sessions (summary only — no per-question detail). Requires admin login.
Request
const res = await fetch('http://localhost:3000/api/sessions', {
credentials: 'include' // sends the session cookie
});
const sessions = await res.json();Response
// 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 a single session. Add ?payload=1 to get the webhook-shaped payload instead of raw session data.
Request
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
// 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" }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.
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
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
// 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" }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.
Fetch all questions for a grade together with their current review status.
Request
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
// 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" }Submit or update a review decision (approve / flag / reset to pending) for one question.
Request
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
// 200 OK
{ "ok": true }
// 403
{ "error": "Not assigned to this grade" }Get the full edit history for a grade — who edited what, when, and what changed.
Request
const res = await fetch('http://localhost:3000/api/review/edit?grade=5', {
credentials: 'include'
});
const { editLog } = await res.json();Response
// 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."
}
]
}Save edits to a question. The API automatically diffs the changes and logs them.
Request
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
// 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" }Per-grade review progress. Managers see only their assigned grades; admins see all 10.
Request
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
// 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" }
]
}
]
}List grade-level approval decisions. Managers see only their own decisions on assigned grades.
Request
const res = await fetch('http://localhost:3000/api/manager/approvals', {
credentials: 'include'
});
const { approvals } = await res.json();Response
// 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"
}
]
}Submit a grade-level approval or revision request. Manager must be assigned to the grade.
Request
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
// 200 OK
{ "ok": true }
// 400 — invalid status value
{ "error": "Invalid status" }
// 403 — not assigned to that grade
{ "error": "Not assigned to this grade" }Get all questions for a grade with any overrides merged in.
Request
const res = await fetch('http://localhost:3000/api/admin/questions?grade=5', {
credentials: 'include'
});
const questions = await res.json(); // Question[]Response
// 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" }Download an Excel workbook with all questions for a grade. Use ?grade=all for every grade.
Request
// 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
// 200 OK Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet Content-Disposition: attachment; filename="mathiq-questions-grade5.xlsx" <binary .xlsx file>
Bulk import questions from an Excel file. Rows with empty id are added; rows with an existing id overwrite that question.
Request
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
// 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"
]
}List all registered users.
Request
const res = await fetch('http://localhost:3000/api/admin/users', {
credentials: 'include'
});
const users = await res.json(); // User[]Response
// 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"
}
]Change a user's role. The email address in the URL path must be URL-encoded.
Request
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 objectResponse
// 200 OK — updated user
{
"email": "teacher@school.com",
"name": "Jane Smith",
"role": "manager",
"createdAt": "2025-04-15T10:30:00Z"
}
// 404
{ "error": "User not found" }Permanently delete a user account.
Request
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
// 200 OK
{ "ok": true }
// 404
{ "error": "User not found" }Full review statistics across all 10 grades with per-reviewer breakdowns.
Request
const res = await fetch('http://localhost:3000/api/admin/review/stats', {
credentials: 'include'
});
const { perGrade, totalQuestions, totalApproved,
totalFlagged, totalPending } = await res.json();Response
// 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 }
]
}
]
}List all teacher reviewer assignments along with all user accounts (for the assignment UI).
Request
const res = await fetch('http://localhost:3000/api/admin/review/assignments', {
credentials: 'include'
});
const { assignments, users } = await res.json();Response
// 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 */ ]
}Assign a teacher to review specific grades. Replaces any existing assignment for that email.
Request
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
// 200 OK
{ "ok": true }Remove all grade assignments from a teacher reviewer.
Request
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
// 200 OK
{ "ok": true }Assign a manager to oversee specific grades. Same pattern as reviewer assignments.
Request
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
// 200 OK
{ "ok": true }Get the complete edit log for a grade (admin view — no grade-assignment check).
Request
const res = await fetch('http://localhost:3000/api/admin/review/log?grade=5', {
credentials: 'include'
});
const { editLog } = await res.json();Response
// 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"
}
]
}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
standard stores one standardQuestions list;adaptive storessession1Questions plussession2QuestionsByBand keyed by the five fixed score bands.90-100, 80-90, 70-80, 60-70, below-60. Every band must have the same total question count..test-templates/{id}.json on the server. Not yet routed through the external provider — only works with DATA_PROVIDER=local.POST /api/sessions flow — history, report, PDF, webhook all work the same way.TemplateConfig (request body shape)
| Field | Type | Req | Description |
|---|---|---|---|
| name | string | ✓ | Admin-chosen display name. |
| grades | number[] | ✓ | 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. |
| durationMinutes | number | ✓ | Total test duration. For adaptive, this is shared across both sessions. |
| standardTargets | DifficultyTargets | — | { 1:N, 2:N, 3:N, 4:N, 5:N } — how many questions per difficulty. Required when mode=standard. |
| session1Targets | DifficultyTargets | — | Required when mode=adaptive. Same shape as standardTargets. |
| session2TargetsByBand | Record<AdaptiveSessionBand, DifficultyTargets> | — | Required when mode=adaptive. One entry per band; all bands must sum to the same total. |
List all custom test templates (summary view — no question contents). Sorted by updatedAt descending.
Request
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
{
"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"
}
]
}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).
shortfalls means every requested question was sourced. Non-empty means you may want to widen the grade pool or lower a target.Request
// 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
{
"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 the full template — config plus locked questions.
Request
const res = await fetch('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…', { credentials: 'include' });
const { template } = await res.json();Response
// 200 OK — { template: TestTemplate }
// 404 — { "error": "Not found" }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
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
// 200 OK — { template: TestTemplate }
// 404 — { "error": "Not found" }Delete a template. Existing student links will start returning 404.
Request
await fetch('http://localhost:3000/api/admin/test-templates/8a1f3c2d-…', {
method: 'DELETE',
credentials: 'include',
});Response
// 200 OK — { "ok": true }
// 404 — { "error": "Not found" }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
const res = await fetch(
'http://localhost:3000/api/admin/test-templates/8a1f3c2d-…/generate',
{ method: 'POST', credentials: 'include' }
);
const { template, shortfalls } = await res.json();Response
// 200 OK — { template: TestTemplate, shortfalls: {...} }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.
bucket=standard. Returns up to 25 results sorted by no particular order (caller can shuffle).Request
// 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
{
"candidates": [
{ /* Question */ }, { /* Question */ }, …
]
}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
// 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
// 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
Read a template for the student-facing runner. Returns enough to render the test (locked questions per bucket) but omits admin metadata (createdBy, etc.).
/t/{id} reads this server-side; you generally won't call it directly. Open and use the runner UI to start the test.Request
const res = await fetch('http://localhost:3000/api/test-templates/8a1f3c2d-…');
const template = await res.json();Response
{
"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" }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:
X-API-Key: <EXTERNAL_API_KEY> Content-Type: application/json
Questions
Return all questions for a grade (with overrides merged).
Request
// 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
// 200 OK
{ "questions": [ /* Question[] */ ] }Upsert a question override. If the question id exists, replace it; otherwise add it.
Request
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
// 200 OK
{ "ok": true }Bulk upsert questions. Empty-id rows get new IDs; existing-id rows are overwritten.
Request
POST https://your-api.example.com/v1/questions/import
X-API-Key: your-secret-key
{ "rows": [ /* Partial<Question>[] from the uploaded spreadsheet */ ] }Response
// 200 OK
{ "added": 3, "updated": 1, "errors": [] }Users
Create a user. Called on first login when the user doesn't exist yet.
Request
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
// 201 Created
{
"email": "newuser@school.com",
"name": "New User",
"image": "",
"role": "student",
"createdAt": "2025-05-03T14:00:00Z"
}Update a user's role or record their last login timestamp.
Request
// 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
// 200 OK — updated User object
Sessions
Create or replace a session. Called every time a test session is saved or updated.
Request
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
// 200 OK
{ "ok": true }Reviews, Edit Log, Assignments, Approvals
| Method | Endpoint | Description |
|---|---|---|
| GET | /reviews | All reviews as Record<questionId, QuestionReview> |
| PUT | /reviews/{questionId} | Upsert a review for one question |
| GET | /edit-log?grade=N | Edit log entries (optional grade filter) |
| POST | /edit-log | Append a new edit log entry |
| GET | /assignments | List 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-assignments | List all manager assignments |
| PUT | /manager-assignments/{email} | Upsert a manager assignment |
| DELETE | /manager-assignments/{email} | Remove a manager assignment |
| GET | /grade-approvals | List all grade approval records |
| PUT | /grade-approvals/{grade}/{managerEmail} | Upsert a grade approval decision |
Error Envelope (all external endpoints)
// 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