Two correctness gaps on the v1.4.41 onboarding-discovery endpoint,
caught in the v1.4.41 QA round:
1. The route called `identifier.toLowerCase()` before the OR-match on
`username` / `email`, but `registerSchema` applies no transform on
write — both columns are stored exactly as typed. A user registered
as `MixedCase@Example.com` would never resolve through this route,
and iOS onboarding would route them to the sign-up branch despite
an existing account.
2. The route header claimed an edge-level limit on `/api/auth/*`
covered brute-force enumeration; no such middleware exists. Sibling
`/api/auth/passkey/login-options` carries a per-route throttle. We
mirror that shape — `checkRateLimit("auth:check-user:<ip>", 30,
15 * 60 * 1000)` — and rewrite the header comment so the rationale
matches reality.
Tests pin both invariants: a `MixedCase@Example.com` probe hits
`findFirst` with the case preserved, and a 429 short-circuits the
`findFirst` call entirely.
v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the tile-strip Suspense
fallback from `null` to an `aria-hidden` placeholder div that mirrors
the trend-card chrome, preventing CLS when a future RSC hoist makes
the tile body suspend. The literal regex pin in the boundary test was
not updated alongside, leaving a red on develop tip.
Updates the regex to match the new structural contract and refreshes
the surrounding comment so future contributors see why the fallback is
no longer `null`.
Narrow each helper's visibility to its actual reach instead of leaving
it exported "just in case":
- Drop the `export` keyword on file-local helpers / constants:
`getTrendSentiment`, `isPRWithin30Days`, `buildCoachMetricSourceType`,
`countPassedSchedules`, `getGithubConfig`, `isComparisonBaseline`,
`DASHBOARD_LAYOUT_VERSION`, `INTL_LOCALE_MAP`, `SIDE_EFFECT_NOTES_MAX`,
`MEDICATION_CATEGORIES`, `openApiBase`, `ALL_MOOD_GRANULARITIES`.
- Remove never-used dead code:
`CodexOAuthNotConfiguredError` (declared as a backwards-compat shim
no caller ever consumed), `editMessageReplyMarkup` (Telegram method
nothing on the bot dispatch tree calls), and the stale
`DISPLAY_TIMEZONE` re-export from `src/lib/format.ts`.
Knip drops from 48 -> 35 unused exports. Pure narrowing / deletion;
no behaviour change.
`recomputeMoodBucketsForEntry`, `recomputeMedicationComplianceForDay`,
and `recomputeMedicationComplianceForEvent` all declared an optional
`tx?` Prisma transaction client but no call site has ever passed one —
every caller (routes, jobs, worker, tests) invokes them outside a
`$transaction` block. The dead surface area kept dragging a
`PrismaLike` / `PrismaTxOrClient` alias plus a top-level `Prisma`
import along with it. With the param gone the helpers simply close
over the singleton `prisma` client; the alias and the
type-only import follow.
No call-site changes needed — the param was optional everywhere.
Both `/api/medications/intake?scope=today` and `/api/dashboard/summary`
carried their own copy of the v1.4.39 projection + idempotent
`createMany` + per-`(med, day)` compliance recompute. The duplicate
implementations had already drifted twice; fold them into one tested
location at `src/lib/medications/scheduling/project-today-intakes.ts`
so future changes (new IntakeSource literal, new schedule cadence,
audit metadata) only land once.
The helper stays read-shape-neutral — the two routes return different
projections of the resulting events, so the caller still owns its
typed re-read. Returns `{ projected, backfilled }` for the existing
`annotate` telemetry.
Net: ~200 lines collapsed into 145 (helper) + two short call sites.
The insights page kept `CorrelationResult` + `DataSummary` type imports
left over from an earlier refactor, and the slim summaries slice still
referenced `RollupGranularity`, `aggregateWmyBuckets`, and
`RollupBucketRow` after the v1.4.37.2 GROUP BY rewrite folded that work
into the narrow aggregate. Pure deletions — both helpers stay exported
for the test suite that still exercises them.
Mirror the bmi-status (v1.4.37) timeout-stub persist into the two
sibling endpoints. On a 20 s provider stall the route now writes a
sentinel auditLog row keyed to today's Berlin day so the next mount
short-circuits at the cache lookup instead of re-racing the same
provider call for the rest of the day.
This eliminates the ~14 s warm response on every iOS dashboard open
when the provider has stalled once that day. Response envelope
unchanged; iOS v0.5.4 contract preserved.
Tests cover both arms: the persist-on-timeout path (one create with
timeout:true, model:"timeout-stub", today's dateKey) and the
short-circuit-on-stub path (subsequent mount returns cached text,
provider never invoked).
The `sum_value IS NULL` UNION arm in `enqueueBootTimeRollupBackfill`
was added in v1.4.39 W-SUM to converge DAY rollup rows that pre-dated
the writer change. Production data has since converged, so the arm is
a permanent no-op seq scan over `measurement_rollups`. Per-day missing
coverage remains the sole discovery anchor.
Drops the legacy text-anchor unit test. The read-side `mean * count`
fallback in `/api/dashboard/summary` retains legacy-row readability
and stays covered by its dedicated route test.
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per-
metric system prompts + safety contracts) and `src/lib/insights/prompt
*.ts` (user-prompt builder + comparison snapshot + the `compactSections`
helper). A new reader could not tell from filenames which file to
extend; both directories looked equally authoritative. Audit rec #3
flagged the split as the only undiscoverable seam in the AI layer.
Move both files into `src/lib/ai/prompts/`:
- `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts`
- `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts`
- co-located tests follow
Three importers updated: `app/api/insights/generate/route.ts`,
`lib/ai/coach/snapshot.ts`, the generate route test (the
`vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have
ONE home and ONE mental model.
v1.4.41 W-DELETED-2 — assert that /api/export, /api/export/full-backup,
and /api/export/measurements all scope the measurement read to
deletedAt: null. Shape-level guard so a future refactor cannot silently
drop the filter and resurrect deleted rows in user-downloaded files.
Routes the registration-status, auth invalidation, notifications
preferences, and Settings about-section api-version reads through
the centralised queryKeys factory. Adds factory entries for the
remaining settings/admin surfaces a follow-up will migrate, and
extends the walker test to fail on any future bare-literal
queryKey in src/app/auth, src/app/notifications, and the
about-section. Also swaps the dashboard tile Suspense fallback
from null to a layout-stable card placeholder so a future RSC
hoist of any tile slot can't trigger CLS.
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:
- not_found no account
- passkey_only passkey present, no password
- email_fallback password present (passkey optional)
- exists neither credential (recovery path)
The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.
Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
v1.4.41 W-DELETED-2 — achievement progress now scopes the measurement
read to deletedAt: null so deleting a row immediately rolls back any
streak or count badge it had earned, matching the analytics + dashboard
sweeps from v1.4.40.
v1.4.41 W-DELETED-2 — the doctor-report-data aggregator (shared by
/api/doctor-report JSON + /api/doctor-report/pdf server render) and the
section-availability probe now filter deletedAt: null so deleted rows
never appear in the rendered PDF or trigger an empty section toggle.
Three structurally-distinct `interface AnalyticsData {…}` declarations
shared a name across `src/app/page.tsx` (dashboard), `src/app/insights/
page.tsx` (insights mother), and `src/components/onboarding/
getting-started-checklist.tsx` (checklist). The collision made the
shapes un-discoverable from any one site and forced every iOS-contract
addition to land in three places.
Hoist all three into `src/types/analytics.ts` as named exports:
`DashboardAnalyticsData`, `InsightsAnalyticsData`, `ChecklistAnalytics
Data`. Each call site re-imports its shape under the original alias to
keep downstream code readable; the slim `SubPageAnalyticsData` that was
already there stays put. We deliberately keep the four shapes distinct
(rather than collapsing to one with everything optional) so call sites
can't cross-read fields they shouldn't reach for — closes v1.4.39 org-
audit recommendation #2.
v1.4.41 W-DELETED-2 — full-backup JSON, per-type CSV, and the legacy
/api/export endpoint now scope reads to deletedAt: null so iOS undo
or pending-sync rows never reach the user's downloaded file or the
admin restore round-trip.
Resolves the single component → route-handler import in the codebase
(`components/admin/backups-section.tsx ← app/api/admin/backups/route.ts`)
flagged by the v1.4.39 org audit (recommendation #2). The DTOs now live
in `src/types/backups.ts`; the route handler keeps owning the HTTP
contract and re-imports the shared types from there.
`z.string().max()` counts JavaScript string units (UTF-16 code
units), not bytes. The audit-table guarantee for SB-10 consent
artefacts is byte-bounded — a UTF-8 artefact full of multi-byte code
points (CJK, emoji) would otherwise slip past the 64 KB row budget.
Replace the `.max()` with a `.refine()` that uses
`Buffer.byteLength(value, "utf8")`.
Closes security M1 from the v1.4.40 QA round.
The compare-toggle component still declared three bare-literal
`["user", "dashboardWidgets"]` queryKeys for read, onMutate
cancel/set, onError rollback and onSettled invalidation. Migrate every
call site to the central `queryKeys.dashboardWidgets()` factory so a
future rename or shape evolution only touches one file. Extend the
W-RSC factory-bypass walker to cover `src/components/comparison/`
so any future regression in this directory gets caught by CI.
Closes UX H1 from the v1.4.40 QA round.
The recompute helper swallows errors internally today, but the contract
is fragile — any future refactor that lets a throw escape would turn
the intake POST or dashboard summary GET into a 5xx for a strictly
best-effort hook. Wrap both call sites in `Promise.allSettled` so the
fail-soft contract is explicit at the call site, not buried in the
helper.
Closes specialist H1 from the v1.4.40 QA round.
The audit-H4 regex still pinned the pre-W-INFRA Thread 2 form
`useMemo(..., [user?.timezone])` while the production code lifts
`user?.timezone` to a `userTimezone` local one line above the
`useMemo` so the dep array stays stable. Update the test fixture to
match the new dependency form.
Closes senior C1 from the v1.4.40 QA round.
- src/lib/medications/compliance-rollups.ts → src/lib/rollups/medication-compliance-rollups.ts
- co-located test follows the source file
10 importers updated covering the dashboard summary, every intake CRUD
route (single + bulk + import + Telegram webhook), the ingest endpoint,
the admin backups restore path, and the reminder worker job.
Closes the rollup umbrella restructure — every persistent-rollup
producer + reader now lives under src/lib/rollups/. Pure relocation
across all five batches: zero behaviour or exported-surface change.
- src/lib/mood/rollups.ts → src/lib/rollups/mood-rollups.ts
- co-located test follows the source file
17 importers updated from @/lib/mood/rollups to @/lib/rollups/mood-rollups,
covering the mood-entries CRUD routes, the moodlog webhook, the import
restore path, the insights comprehensive + targets routes, the analytics
+ features paths, and the reminder worker + moodlog sync job consumers.
Pure relocation.
- src/lib/measurements/rollup-read-wmy.ts → src/lib/rollups/measurement-read-wmy.ts
- src/lib/measurements/rollup-read-cumulative.ts → src/lib/rollups/measurement-read-cumulative.ts
- co-located tests follow the source files
- cumulative module's sibling import of apple-health-mapping promoted to
the @/lib alias since that mapping stays in src/lib/measurements/
Importers updated: 2 sites for WMY (health-score + summaries-slice),
zero external sites for cumulative (only co-located test reads it).
Pure relocation.
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:
- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
src/lib/rollups/__tests__/measurement-rollups.test.ts
Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.
Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
The useMemo introduced for the timezone-aware greeting in the W-RSC
per-tile Suspense pass listed [user?.timezone] as its dep array. React
Compiler's preserve-manual-memoization rule rejected the literal
optional-chain expression because the inferred dep is the wider 'user'
object — a less-specific source than the declared property access — so
the compiler refused to optimize the surrounding 1 400-line component
body.
Lift userTimezone into a local before the memo so the inferred dep and
the declared dep agree. No behaviour change; the memo still recomputes
only when the user's timezone string changes.
The three Measurement reads in the analytics route — glucose-by-context,
sleep-stage breakdown, and the fetchMeasurementSeriesChunked paged
helper that backs the per-type summarize loop and the BP-Zielbereich
windowing — were still pulling soft-deleted rows. W-DELETED skipped this
file assuming the W-POOL pool / p-limit pass covered it, but that pass
only addressed concurrency, not the deletedAt predicate.
Adds deletedAt: null to all three findMany clauses so the route shares
the same soft-delete contract as every other analytics read path. iOS
soft-delete unblocked.
The compliance-rollups module and the cadence scheduling module each
carried a private wallClockInTz definition. The cadence variant
returned the full {year,month,day,hour,minute,second,weekday} tuple;
the compliance variant returned only {year,month,day}. Promote one
canonical helper that returns every part — the compliance caller
destructures the three fields it needs — so the cross-tz ±3 h guard
stays in lock-step instead of drifting between sibling modules.
The mood-rollup and measurement-rollup writers carried byte-identical
copies of the helper that could drift independently. Promote one
canonical export under src/lib/tz/ so both writers — and any future
rollup table — import the same UTC-anchored truncation.
The runtime never reads the format namespace — every locale-aware
formatter routes through Intl.DateTimeFormat directly via
src/lib/format-locale.ts. The keys were only verified by their own
ordering smoke test, which is also removed.
The glucose UI only converts canonical mg/dL into mmol/L for display
via convertGlucose; the reverse helper had no caller outside its own
test. The Berlin-only day-key helper was already superseded by
userDayKey(date, tz) in v1.4.38 for every read path that consumed it;
its remaining presence as a tested module was dead weight.
Pins the cross-consumer routing contract the v1.4.40 wire-in relies
on. Both computeSummariesSlice (year-ago baseline) and
computeUserHealthScoreFastPath (weight long-window) now call
readBestGranularityRollups so the floor table is shared state. The
new tests prove:
- 90 d -> DAY (every coarser floor too high),
- 365 d -> MONTH (clears 181 d floor, short of 731 d YEAR floor),
- 1095 d -> YEAR (clears the 731 d YEAR floor).
Plus a parity assertion: a MONTH bucket consolidating 30 measurements
with mean 80 returns the same count-weighted mean as the two DAY
buckets (count 10 / mean 82 + count 20 / mean 79) that compose it.
Locks the linearly-composable contract count / mean carries across
granularities — any future floor adjustment forces both consumers to
re-pin in lock-step rather than silently diverging.
The bucket-fresh read paths probe coverage per-type via
coverage.get(type) or rely on isFullyCovered for the all-or-nothing
fast path; the typesMissingCoverage helper had no production caller.
The cumulative-batch reader was only exercised by its own test —
the analytics A2 path took the chunked per-type findMany route in
production. Strip both and their tests.
Wires readBestGranularityRollups into the WEIGHT-covered branch of
computeUserHealthScoreFastPath so the trailing-year weight baseline is
served from the WEEK / MONTH / YEAR rollup tier instead of falling
through to a live measurements walk. The 365-day window routes through
MONTH by default (~12 buckets) and falls back to WEEK -> DAY on per-
tier coverage miss. count / mean compose linearly across MONTH buckets
so the derived mean is mathematically equivalent to the per-row
average over the same window.
The read fires in parallel with the canonical DAY-bucket read for the
30-day weight series so wall-clock cost stays flat. Score shape is
unchanged; the weightLongWindow annotate carries the resolved
granularity + bucket count + mean so operators can verify the rollup
tier serves production traffic. The live-fallback branch deliberately
omits the long-window probe because the raw weight findMany already
does the work — guards against double-reading on cold mounts.
Neither function had any production caller. The chart components in
src/components/charts/* implement a local movingAverageByPoints rather
than reaching for the lib helper, and weeklyAverages was never imported
outside its own test file. detectAnomalies stays — it is reached
transitively through summarize() which the analytics, insights, and
measurements-series routes all consume.
Both routes were superseded by their /api/admin/monitoring/{umami,glitchtip}-test
counterparts which are the variants the admin UI actually fetches.
The duplicated routes had no callers anywhere in the source tree.
The GET handler had no fetch caller and the queryKeys.medicationIntakeSummary
entry was never consumed in any useQuery. Strip the route file, the
factory entry, the medicationDependentKeys reference, and an outdated
quick-add doc comment.
Wires readBestGranularityRollups into computeSummariesSlice to populate
the year-ago 30-day baseline (avg30LastYear) per type. The field was
hardcoded null up to v1.4.39 because the 90-day windowed aggregate
cannot reach back a year and the live measurements walk over a 395-day
per-type window is exactly the cold-path cost the rollup tier was meant
to absorb.
Coverage-fallback mirrors the W-MOOD posture: per-type calls route
through YEAR (skipped for the 395-day window — floor 731 d) -> MONTH
-> WEEK -> DAY and return null when no granularity carries any bucket
overlapping the [now-395d, now-365d) slice. count / mean compose
linearly across any granularity so the derived baseline is
mathematically equivalent to the per-row average over the same window.
Adds year_over_year_types annotate count alongside the existing
slim_summaries path tag so operators can verify the wire-up in
production wide-events without redeploying instrumentation.
The queue had a handler, registration, and worker binding but no
producer anywhere in the codebase — zero boss.send calls and no cron
schedule. The doc comment claimed the Telegram sender schedules
cleanup but the sender never called boss.send. Strip the constant, the
interface, the handler, the allQueues entry, and the worker bind.
The route is read-only and its envelope shape (`registrationEnabled`
boolean) gates iOS sign-in + web onboarding. Four branches walked:
1. singleton row exists with registrationEnabled: true → reports true
2. singleton row exists with registrationEnabled: false → reports
false (admin-disabled tenant, invite-only)
3. singleton row missing → reports schema default true (fresh self-
hosted instance, no-config baseline)
4. findUnique throws → reports false (fail-closed; critical defense
against a DB blip silently unlocking sign-up on a tenant that
disabled registration)
No code change; the route's current behaviour is correct. Pin it
before someone refactors fail-closed → fail-open by accident.
Mirrors the intake-route call site (v1.4.40 close of v1.4.39.4 hook
gap). `projectAndReadTodaysIntakes` runs the same idempotent
projection helper the intake route uses; without this hook the
compliance rollup stayed pre-projection on the second call site too.
Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` after the bulk `createMany`, best-effort
inside the helper's try/catch so a populator hiccup doesn't surface
as a 5xx on the dashboard summary.
Closes the v1.4.39.4 hook gap surfaced by the QA reconcile pass. The
`scope=today` branch's bulk `createMany` mints fresh
`(medicationId, scheduledFor)` PENDING rows when the morning
projection lands; without this hook the compliance rollup for the
affected `(user, medication, day)` tuples stays at its pre-projection
`scheduled` count, inflating the apparent compliance % until the user
actually logs against the new row.
Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` (coalesced by Set) so a 5-meds-3-times-a-
day projection drops 15 createMany rows down to ≤3 recompute round-
trips. Best-effort: errors stay caught inside the helper so a
populator hiccup doesn't surface as a 5xx on the intake request.
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The mood
block inside `extractFeatures` ran an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` and folded the
entire history into the AI feature payload for /api/insights/generate.
On power-user accounts the walk dominated the cold-mount budget.
Swap to `readMoodDayRollups(userId, fiveYearsAgo)` so the feature
payload reads one row per calendar day. The downstream consumers
(`avg7` / `avg30` / `trend30` / `coverage` / cross-metric
correlations) all operate at per-day resolution, matching the rollup
shape. `latest` score reads a single `findFirst` (bounded). Coverage-
fallback runs a bounded one-year live walk + fires
`ensureUserMoodRollupsFresh` so the next request lands on the rollup
tier (same posture as /api/mood/analytics post-v1.4.39).
Existing test fixture updated to mock `moodEntryRollup` + the
warm-up; six features tests stay green.
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day
`prisma.moodEntry.findMany` block fed two downstream consumers:
`dailyMoodEntries` for mood × metric correlations, and `moodSummary`
for the dashboard mood block. Both read at a per-day resolution.
Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads
one row per calendar day instead of one per entry. Coverage-fallback
runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day
mean DataPoints into `summarize()` on both branches, matching the
rollup-tier convention shipped in v1.4.39 (QA UX-H1).
Two parity tests added to `__tests__/route.test.ts`: rollup fast path
skips the raw findMany, and the coverage-fallback runs the live walk
exactly once when rollups are empty.
Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db).
The mood block at the bottom of the route used to run an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` on every mount —
pulling every mood the user had ever logged into Node only to bucket-
average them in JS. The `findMany({distinct:['type']})` for 'absolute
latest measurement per type' carried no `measuredAt >=` floor either,
so on a 347 k-row tenant Postgres still had to sort the full set
before the driver applied DISTINCT ON (Prisma's `distinct` doesn't
compile to PG's `DISTINCT ON`).
This commit:
- swaps the mood findMany for `readMoodDayRollups(userId, since)`
plus a single `findFirst` for the latest score; coverage-fallback
runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39)
- adds a 365-day floor to the latest-ever-per-type read so the
driver-side distinct walk stays bounded
- pins all three behaviours in `__tests__/route.test.ts` (4 new
tests: rollup fast path, coverage-fallback bounded walk, no-mood
branch, distinct floor present)
Three test suites land together:
1. queryKeys factory contracts for the four new v1.4.40 entries
(`authMe`, `userThresholds`, `chartData`, `dashboardMedicationCompliance`)
— pin the byte-stable shapes so a future rename of any literal
lands a matching test failure rather than silently breaking cache
layout.
2. `measurementDependentKeys` / `medicationDependentKeys` bundle
membership — `["chart-data"]` and
`["dashboard-medication-compliance"]` prefixes now ride the
bundles so a mutation evicts every per-chart and per-compliance
cache in one pass (audit-C2 + audit-L4).
3. Factory-bypass guard — walks `src/components/charts`,
`src/app/page.tsx` and `src/hooks/use-auth.ts` and fails if a
literal `queryKey: [...]` slipped past the factory. Acts as a
lint-style enforcement check in the absence of a custom ESLint
rule. The guarded directory list is intentionally scoped tight to
the files this wave migrated; a follow-up wave can extend it as
it routes the remaining admin/settings/medications surfaces
through the factory.
Plus a dashboard Suspense-boundary structural test that pins the
per-tile `<Suspense fallback={...}>` wrappers, the module-scope
`DASHBOARD_QUERY_OPTS` hoist, and the `useMemo` around the hour-of-day
derivation. A refactor that drops any of those three structural
guarantees lands red CI rather than silently regressing the
streaming-composition contract.
All 16 + 5 new assertions green; tests 4565 → 4586.
Cases covered:
- POST 401 unauth, 400 invalid kind, 400 non-ISO signedAt, 400 oversize
artefact (>64 KB), 200 inserts row + strips artefact from response.
- GET 401, 400 invalid kind, null when nothing granted, latest row by
kind, full keyspace projection when ?kind is omitted.
- DELETE 401, flips revokedAt on the latest active row, idempotent
no-op when nothing to revoke, master toggle revokes per-kind.
The headline test is the append-only invariant: a revoke + re-grant
cycle leaves the original row in place (with revokedAt set), mints a
fresh row, and a subsequent GET points at the re-grant — never at the
revoked predecessor. Prisma's `delete` is asserted to never be called
on a receipt, which is the legal-team guarantee the audit trail relies
on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the iOS onboarding + Settings "AI deaktivieren" flow to the
persistence layer landed in the previous commit:
- POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists
a fresh receipt, returns id + serialised row (artefact stripped from
the response so the signed token doesn't echo over network paths it
doesn't need to cross).
- GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for
the given kind. Omit ?kind to get the full three-kind keyspace
keyed by kind, with null slots for kinds the user hasn't granted.
- DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the
latest active row. Idempotent: a no-op returns 200 with null receipt
rather than 404 so the iOS toggle can hammer the endpoint without
spurious error states. Omit ?kind for the master "deaktivieren alle
KI-Funktionen" sweep across every kind.
Both grant + revoke write to auditLog so the trail is reconstructible
even if the receipts table is rebuilt from a backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>