Report question: skip a broken card and tell the server

A generated matching card arrived on a phone with three identical
definitions (the generation guards now drop those), but there was no way
to get past a bad card from inside a session. The feed chrome (deck
study, review, mistakes and exam) now has the red "!" control: one tap
records the question, skips to the next card and shows an Undo toast.

- lib/question-reports.ts: the local list (freezmo.reports.v1, zod on
  read, capped 200) is the skip state — reported cards leave every
  session type, count as "not attempted" in the results instead of
  wrong, and Undo takes a report back. A deck where everything is
  reported stays studiable so it can be regenerated.
- POST /api/reports appends one JSON line per report to
  REPORTS_DIR/reports.jsonl (shared/freezmo/reports in production, the
  dev data dir in dev). Deliberately write-only: the question text is
  readable on the server (that is the point — prompt tuning) but never
  over HTTP, since the app has no auth. Best effort: offline reports stay
  local.
- The button: a red outlined rounded square with "!", the chrome's
  control size, in both feed headers (hidden on the results slide).
- config: REPORTS_DIR + RATE_LIMIT_REPORTS (20); the deploy creates the
  shared reports dir; the dev unit and AGENTS.md note the new server
  data location.
- Suites: phase-1 (append + validation + no GET), phase-2 (button, toast,
  server POST, local copy, skip counter, hidden next session, Undo, exam
  pool). SW -> freezmo-v38.
This commit is contained in:
Martin Dvořák 2026-10-12 07:12:55 +00:00
commit 9cea13496a
16 changed files with 496 additions and 15 deletions

60
app/api/reports/route.ts Normal file
View file

@ -0,0 +1,60 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { getServerConfig } from "@/lib/config";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Question reports. Write-only on purpose: the app has no auth, so nothing
* here is readable over HTTP. Every report is appended as one JSON line to
* `<reports dir>/reports.jsonl` under the shared data directory, where the
* operator can inspect it:
*
* tail -f ~/freezmo-prod/shared/freezmo/reports/reports.jsonl
*
* The browser keeps its own copy of the reports — it is the source of truth
* for skipping and Undo. This file is the cross-device record for tuning the
* generation prompts.
*/
const reportSchema = z.object({
deckId: z.string().min(1).max(80),
deckTitle: z.string().max(200).optional(),
activityId: z.string().min(1).max(80),
type: z.string().min(1).max(40),
question: z.string().min(1).max(600),
at: z.number(),
});
/** A report is tiny; anything bigger is not one. */
const MAX_BODY_BYTES = 4096;
export async function POST(request: Request) {
const locale = requestLocale(request);
const crossOrigin = rejectCrossOrigin(request);
if (crossOrigin) return crossOrigin;
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
if (oversized) return oversized;
const config = getServerConfig();
const limit = rateLimit(`reports:${clientKey(request)}`, config.rateLimits.reports, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale);
const parsed = reportSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "invalid report" }, { status: 400 });
const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() });
try {
await fs.mkdir(config.reports.dir, { recursive: true });
await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8");
} catch {
return Response.json({ error: "could not store the report" }, { status: 500 });
}
return Response.json({ ok: true });
}