GET /api/v1/assessment/:id returns 200 with 3 questions, but the take page renders 0 question blocks. The payload questions are legacy rows that carry no id / questionId / questionid key at all (the demo seed predates the id schema). The page builds its list with quiz.questions.filter((q) => q.id), so every question is undefined-keyed and dropped. The empty form then submits answers: {} and the API responds with a bare Validation failed.
No EduOS repo exists in this sandbox — the task is self-contained and the deliverable is the solution markdown. Here it is.
/quiz/<id>/take renders 0 questions when legacy quiz rows have no question idGET /api/v1/assessment/:id returns 200 with 3 questions, but the take page renders 0 question blocks. The payload questions are legacy rows that carry no id / questionId / question_id key at all (the demo seed predates the id schema). The page builds its list with quiz.questions.filter((q) => q.id), so every question is undefined-keyed and dropped. The empty form then submits answers: {} and the API responds with a bare Validation failed.
The fix has three parts:
QuizService.get() backfills a deterministic id ${quizId}:question:${index} for every id-less question, and returns the normalized shape from the single method used by render, draft save, submission, and grading.q.id; render the normalized array directly (with a defensive client-side id fallback), and use q.id consistently as the React key, answer-map key, and submit key.Lesson: verify the live payload's id-key shape (
jq '.questions[] | keys') before assuming a client hydration bug.
Inspect the actual response, not the seeded fixture:
curl -s http://localhost:3000/api/v1/assessment/demo-quiz-1 | jq '.questions[] | keys'
Legacy demo rows produce:
["prompt","options","correctIndex"]
["prompt","options","correctIndex"]
["prompt","options","correctIndex"]
No id, no questionId, no question_id. This is a data-shape regression in stored legacy rows, not a hydration/render bug.
The page does roughly:
// ❌ drops id-less legacy questions
const questions = quiz.questions.filter((q) => q.id);
filter((q) => q.id) keeps only truthy ids. With the payload above, the result is [], so questions.map(...) renders nothing.
With an empty list there is nothing to answer, so the client posts:
{ "answers": {} }
The server's schema expects one entry per question and replies with an unhelpful generic error:
422 { "message": "Validation failed" }
The empty POST is a symptom; the root cause is the id-less payload combined with the client filter.
QuizService.get()Make get() the single normalization point so every consumer (take render, draft save, submission, grading) agrees on ids. A deterministic ${quizId}:question:${index} is stable across requests and restarts, so a draft saved before a fix still maps onto the same question after it.
apps/api/src/quiz/quiz.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
export interface QuizQuestion {
id: string; // canonical, guaranteed
prompt: string;
options?: string[];
correctIndex?: number;
questionId?: string; // legacy alias kept in sync when present
question_id?: string; // legacy alias kept in sync when present
[key: string]: unknown;
}
@Injectable()
export class QuizService {
constructor(
private readonly repo: Repository<Quiz>,
/* ...deps used by submit/grade... */
) {}
async get(quizId: string): Promise<QuizView> {
const quiz = await this.repo.findOne({ where: { id: quizId } });
if (!quiz) throw new NotFoundException(`Quiz ${quizId} not found`);
return {
...quiz,
questions: this.normalizeQuestions(quizId, quiz.questions),
};
}
/**
* Guarantees every question has a stable, non-empty `id`.
*
* Legacy demo rows were persisted before the id schema existed and have
* no id/questionId/question_id. We backfill `${quizId}:question:${index}`,
* which is deterministic (same input -> same id) and unique per quiz.
* Existing ids are preserved verbatim; legacy alias fields, when present,
* are kept in sync so both old and new consumers read the same value.
*/
normalizeQuestions(quizId: string, raw: unknown): QuizQuestion[] {
const questions = Array.isArray(raw) ? raw : [];
return questions.map((entry, index) => {
const question = { ...(entry as Record<string, unknown>) } as QuizQuestion;
const existing =
question.id ?? question.questionId ?? question.question_id;
const hasExisting =
existing !== undefined &&
existing !== null &&
String(existing).trim() !== '';
const id = hasExisting
? String(existing)
: `${quizId}:question:${index}`;
question.id = id;
// Keep aliases consistent only when the legacy consumer already used them.
if ('questionId' in question || !hasExisting) question.questionId = id;
if ('question_id' in question || !hasExisting) question.question_id = id;
return question;
});
}
}
Wire the same normalization into the other paths that read stored questions. They must not re-read the raw row and match on q.id:
// quiz.service.ts — submission
async submit(quizId: string, answers: Record<string, unknown>) {
const quiz = await this.get(quizId); // normalized ids
const questions = quiz.questions;
const missing = questions
.filter((q) => answers[q.id] === undefined || answers[q.id] === null)
.map((q, i) => i + 1);
if (missing.length) {
throw new BadRequestException({
message: 'Validation failed',
details: { missingQuestionIndexes: missing },
});
}
// ...grade against `questions[i].id`...
}
If the DB itself should be repaired (so other raw queries/caches stop seeing id-less rows), run a one-time idempotent migration:
// migrations/<ts>-backfill-quiz-question-ids.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BackfillQuizQuestionIds1710000000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
const rows = await q.query<{ id: string; questions: unknown }>(
'SELECT id, questions FROM quiz',
);
for (const row of rows) {
const questions: unknown[] = Array.isArray(row.questions)
? row.questions
: [];
const normalized = questions.map((entry, index) => {
const question = { ...(entry as Record<string, unknown>) };
const existing =
question.id ?? question.questionId ?? question.question_id;
const hasExisting =
existing !== undefined &&
existing !== null &&
String(existing).trim() !== '';
if (!hasExisting) question.id = `${row.id}:question:${index}`;
return question;
});
await q.query('UPDATE quiz SET questions = $1 WHERE id = $2', [
JSON.stringify(normalized),
row.id,
]);
}
}
public async down(): Promise<void> {
/* non-destructive backfill: nothing to undo */
}
}
q.idThe backend now guarantees ids, but the client should still be defensive so a stale cached payload can never render an empty quiz. Remove the truthy filter and derive a fallback id from the quiz id when needed.
apps/web/src/pages/QuizTakePage.tsx
import { useMemo, useState } from 'react';
interface Question {
id?: string;
questionId?: string;
question_id?: string;
prompt: string;
options?: string[];
}
function questionKey(quizId: string, q: Question, index: number): string {
return (
q.id ?? q.questionId ?? q.question_id ?? `${quizId}:question:${index}`
);
}
export function QuizTakePage({ quiz }: { quiz: { id: string; questions: Question[] } }) {
// ✅ No `.filter((q) => q.id)` — render every question, always.
const questions = useMemo(
() =>
(quiz.questions ?? []).map((q, index) => ({
...q,
id: questionKey(quiz.id, q, index),
})),
[quiz.id, quiz.questions],
);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const isAnswered = (q: { options?: string[] }, value?: string) =>
q.options && q.options.length > 0 ? value !== undefined && value !== '' : !!value?.trim();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const nextErrors: Record<string, string> = {};
const missingIndexes: number[] = [];
questions.forEach((q, index) => {
if (!isAnswered(q, answers[q.id])) {
nextErrors[q.id] = `Please answer question ${index + 1}.`;
missingIndexes.push(index + 1);
}
});
if (missingIndexes.length) {
setErrors(nextErrors);
setFormError(
`Please answer question${missingIndexes.length > 1 ? 's' : ''} ` +
`${missingIndexes.join(', ')} before submitting.`,
);
return; // 🛑 block the submit — never POST an incomplete answer map
}
setErrors({});
setFormError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/assessment/${quiz.id}/submit`, { answers });
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{questions.map((q, index) => (
<fieldset key={q.id} className="rounded-lg border p-4">
<legend className="font-medium">
Question {index + 1}: {q.prompt}
</legend>
{(q.options ?? []).map((option) => (
<label key={option} className="flex items-center gap-2">
<input
type="radio"
name={`q-${q.id}`}
value={option}
checked={answers[q.id] === option}
onChange={() =>
setAnswers((prev) => ({
...prev,
[q.id]: option,
}))
}
/>
{option}
</label>
))}
{errors[q.id] && (
<p role="alert" className="text-sm text-red-500">
{errors[q.id]}
</p>
)}
</fieldset>
))}
{formError && (
<p role="alert" className="text-sm text-red-500">
{formError}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit answers'}
</button>
</form>
);
}
Validation failed actionable (server side)Never return a bare Validation failed. Surface which questions are missing so the client and logs can explain it:
throw new BadRequestException({
message: 'Validation failed',
details: {
missingQuestionIds: missingIds,
missingQuestionIndexes: missing,
},
});
QUIZ=demo-quiz-1
curl -s "http://localhost:3000/api/v1/assessment/$QUIZ" \
| jq '{count: (.questions|length), ids: [.questions[].id]}'
Expected:
{ "count": 3, "ids": ["demo-quiz-1:question:0","demo-quiz-1:question:1","demo-quiz-1:question:2"] }
Assert none are null/empty:
curl -s "http://localhost:3000/api/v1/assessment/$QUIZ" \
| jq -e '[.questions[].id | select(. != null and . != "")] | length == (.questions|length) or (.questions|length)' >/dev/null \
&& echo "PASS: all questions have ids"
// quiz.service.spec.ts
it('backfills deterministic ids for legacy id-less questions', () => {
const svc = new QuizService(null as any);
const input = [{ prompt: 'A' }, { prompt: 'B' }];
const out = svc.normalizeQuestions('q1', input);
expect(out.map((q) => q.id)).toEqual([
'q1:question:0',
'q1:question:1',
]);
// idempotent: normalizing again changes nothing
expect(svc.normalizeQuestions('q1', out).map((q) => q.id)).toEqual([
'q1:question:0',
'q1:question:1',
]);
});
it('preserves existing ids and legacy aliases', () => {
const svc = new QuizService(null as any);
const out = svc.normalizeQuestions('q1', [{ questionId: 'legacy-7', prompt: 'A' }]);
expect(out[0].id).toBe('legacy-7');
expect(out[0].questionId).toBe('legacy-7');
});
Run:
pnpm --filter api test -- quiz.service.spec.ts
With Playwright:
test('take page renders all legacy questions and blocks incomplete submits', async ({ page }) => {
const posted: string[] = [];
await page.route('**/api/v1/assessment/*/submit', (route) => {
posted.push(route.request().postData() ?? '');
return route.fulfill({ status: 200, body: '{"ok":true}' });
});
await page.goto('/quiz/demo-quiz-1/take');
await expect(page.getByRole('group')).toHaveCount(3); // 3 blocks, not 0
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByRole('alert').first()).toContainText(/answer question 1/i);
expect(posted).toHaveLength(0); // no empty POST
// answer all three, then it posts a complete map
const groups = page.getByRole('group');
for (let i = 0; i < 3; i++) {
await groups.nth(i).getByRole('radio').first().check();
}
await page.getByRole('button', { name: /submit/i }).click();
await expect.poll(() => posted.length).toBe(1);
const body = JSON.parse(posted[0]);
expect(Object.keys(body.answers)).toHaveLength(3);
expect(Object.keys(body.answers)).toEqual([
'demo-quiz-1:question:0',
'demo-quiz-1:question:1',
'demo-quiz-1:question:2',
]);
});
# Fails if the take page still discards questions by id
! grep -R "questions.filter((q) => q.id)" apps/web/src/pages/QuizTakePage.tsx
GET /api/v1/assessment/:id returns every question with a non-empty id./quiz/<id>/take renders N blocks for N questions, including legacy rows.${quizId}:question:${index}.QuizService.get(), so they agree.# Evidence - Problem class: quiz-take-zero-questions-legacy-questions-missing-id - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T05:27:53.461Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "EduOS /quiz/<id>/take rendered 0 question blocks despite GET /api/v1/assessment/:id returning 200 with 3 questions. Root cause: legacy demo quiz rows have NO id/questionId/question_id on questions; the take page filters quiz.questions.filter(q=>q.id) and drops every question, submit then POSTs 0 answers and the API replies bare Validation failed. Fix: QuizService.get() backfills deterministic ids <quizId>:question:N for id-less legacy questions (shared by render/drafts/submissions/grading) plus inline per-question validation guidance blocking incomplete submits. Lesson: verify the live payload id-key shape BEFORE assuming a hydration bug.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "quiz-take-zero-questions-legacy-questions-missing-id", "provider": "openrouter", "solved_at": "2026-09-14T05:27:53.461Z", "version": ""}