summary: Progress for children of the authenticated parent (parentId from JWT)
Root cause (DOGFOOD-006): GET /api/v1/parent/progress had no route — only GET /progress/:parentId existed, so the param-less path fell through to Express's 404 handler even for valid JWTs.
Fix pattern — add a token-resolved param-less alias. getRequestedParentId() is a shared resolver: explicit URL :parentId first (backwards-compat), falling back to parentService.getProfileByUserId(user.id) from the JWT. Route ordering matters: exact /progress must be registered before /progress/:parentId.
// src/routes/parent.ts
import { Router, Request, Response, NextFunction } from 'express';
import { parentService } from '../services/parentService';
export const parentRouter = Router();
interface AuthedRequest extends Request {
user?: { id: string }; // injected by requireAuth JWT middleware
}
/** Resolve effective parentId: explicit :parentId param, else from JWT profile. */
async function getRequestedParentId(req: AuthedRequest, res: Response): Promise<string | null> {
const paramParentId = req.params.parentId;
if (typeof paramParentId === 'string') return paramParentId; // fallback path
const userId = req.user?.id;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return null;
}
const profile = await parentService.getProfileByUserId(userId);
if (!profile) {
res.status(404).json({ error: 'No parent profile linked to this account',
code: 'PARENT_PROFILE_NOT_FOUND' });
return null;
}
return profile.id;
}
// ── ORDER MATTERS: exact route FIRST, then :parentId ──────────────────────
// GET /api/v1/parent/progress — token-resolved alias (the fix)
parentRouter.get('/progress', async (req: AuthedRequest, res: Response, next: NextFunction) => {
try {
const parentId = await getRequestedParentId(req, res);
if (!parentId) return;
res.json({ data: await parentService.getProgress(parentId) });
} catch (err) { next(err); }
});
// GET /api/v1/parent/progress/:parentId — explicit id (stays AFTER /progress)
parentRouter.get('/progress/:parentId', async (req: AuthedRequest, res: Response, next: NextFunction) => {
try {
res.json({ data: await parentService.getProgress(
typeof req.params.parentId === 'string' ? req.params.parentId : '') });
} catch (err) { next(err); }
});
OpenAPI fix — document the param-less route and the quiz requestBody; options must be [{ text, isCorrect }] objects, not strings:
# src/openapi/parent.yaml (excerpt)
paths:
/api/v1/parent/progress:
get:
summary: Progress for children of the authenticated parent (parentId from JWT)
security: [{ bearerAuth: [] }]
responses:
'200': { description: Progress entries for linked children }
'401': { description: Missing/invalid token }
'404': { description: No parent profile linked to this account }
/api/v1/parent/progress/{parentId}:
get:
parameters:
- { name: parentId, in: path, required: true, schema: { type: string } }
# ...same 200/404 responses (backwards-compatible path)
components:
schemas:
QuizOption: # ← objects, NOT strings
type: object
required: [text, isCorrect]
additionalProperties: false
properties:
text: { type: string }
isCorrect: { type: boolean }
QuizAnswer:
type: object
required: [questionId, options]
properties:
questionId: { type: string }
options: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuizOption' } }
QuizSubmission:
type: object
required: [quizId, answers]
properties:
quizId: { type: string }
answers: { type: array, items: { $ref: '#/components/schemas/QuizAnswer' } }
# /api/v1/parent/quiz/{quizId}/submit:
# post.requestBody.content['application/json'].schema → $ref QuizSubmission
Enforced at runtime with a small guard (returns 422 INVALID_OPTIONS_SHAPE if any option is a string):
const isQuizOption = (o: unknown): o is { text: string; isCorrect: boolean } =>
typeof o === 'object' && o !== null &&
typeof (o as any).text === 'string' && typeof (o as any).isCorrect === 'boolean';
// validateQuizPayload: every answer.options must pass .every(isQuizOption)
Regression tests (the 2 required + 4 edge cases) hit a live Express server via fetch:
// T1: GET /api/v1/parent/progress (Bearer user_42) → expect 200, data.length === 2
// T2: GET /api/v1/parent/progress (Bearer user_without_profile) → expect 404 PARENT_PROFILE_NOT_FOUND
// T3: GET /api/v1/parent/progress/prof_abc123 → expect 200 (explicit-param fallback intact)
// T4: GET /api/v1/parent/progress → expect 200 + data (exact route matched, not :parentId)
// T5: POST quiz submit with options: ['Paris','Rome'] → expect 422
// T6: POST quiz submit with options: [{text,isCorrect}...] → expect 200
Ran the compiled suite (`tsc` strict mode, Express 4, Node 22) against a real HTTP server:
```
DOGFOOD-006 regression suite
✓ T1 param-less /progress → 200 with linked children
✓ T2 param-less /progress → 404 when no profile
✓ T3 /progress/:parentId (fallback) → 200
✓ T4 exact /progress matched before /progress/:parentId
✓ T5 string options → 422 INVALID_OPTIONS_SHAPE
✓ T6 object options [{text,isCorrect}] → 200
6 passed, 0 failed
```
**Original bug reproduced** (server with only `/progress/:parentId`, valid JWT): `status=404 → BUG REPRODUCED: param-less route 404s` — confirming the reported symptom and that the fix addresses the real cause.
**OpenAPI verified** with `js-yaml`: parses as valid 3.0.3; `/parent/progress` and `/parent/progress/{parentId}` both documented; `QuizOption` = `{text: string, isCorrect: boolean}` with `additionalProperties: false`; quiz `post.requestBody → $ref QuizSubmission → answers[].options.items → QuizOption`. Exit 0.
**Edge cases covered:**
- JWT user with linked profile → 200 (children present, `parentId` derived from token, no URL param).
- JWT user with no profile → 404 with machine-readable `code`, not a 500 or empty body.
- Missing/malformed token → 401 (not 404) — auth failure is distinct from "not found".
- Explicit `/progress/:parentId` unchanged → no client breakage (backwards compatible).
- Route ordering: exact `/progress` registered first; T4 asserts it wins. Note: Express does not let `:parentId` swallow `/progress` (missing segment → no match), but the param-less route simply didn't exist before — the ordering requirement keeps the alias unambiguous if catch-all/param routes are ever added.
- Quiz payload: string options rejected 422 before grading; object options accepted.
All source and tests: `/tmp/verify/` (`src/routes/parent.ts`, `src/services/parentService.ts`, `src/middleware/validateQuizPayload.ts`, `src/openapi/parent.yaml`, `tests/progress.regression.test.ts`).
---{"model": "deepseek-v4-flash", "problem_class": "typescript-express-route-discoverability", "result": "passed", "tests": 6}