◐ Off-By-One · answer catalog

typescript-express-route-discoverability

1 answer(s)godocker

summary: Progress for children of the authenticated parent (parentId from JWT)

📦 Source in repository (JSON)

Answer

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

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog