Merge remote-tracking branch 'origin/work/v131-slice3-activity' into release/v1.31.0

# Conflicts:
#	src/lib/jobs/reminder/register-status.ts
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 15:42:11 +02:00
commit 430cbb2ceb
30 files changed
+3245 -45

No files matched your search

+31 -6
View File
@@ -31,6 +31,7 @@ import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
import {
computeZones,
@@ -75,6 +76,9 @@ export const GET = apiHandler(
sampleCount: true,
},
},
insight: {
select: { paragraphEncrypted: true, generatedAt: true },
},
},
});
@@ -205,6 +209,26 @@ export const GET = apiHandler(
}
: null;
// Decrypt the stored paragraph. Fail-SOFT here and only here: `decrypt` is
// fail-closed by design, so a rotated-away key would otherwise turn the
// whole workout-detail page into a 500 over a garnish field. An
// undecryptable paragraph degrades to no card, exactly like a workout that
// never had one.
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
if (row.insight) {
try {
aiInsight = {
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
generatedAt: row.insight.generatedAt.toISOString(),
};
} catch {
annotate({
action: { name: "workouts.detail.insight_undecryptable" },
meta: { workoutId: row.id },
});
}
}
return apiSuccess({
id: row.id,
sportType: row.sportType,
@@ -229,12 +253,13 @@ export const GET = apiHandler(
zones,
splits,
sportContext,
// Reserved Activity-Insight seam (strategic-concept Wave C, bet 5):
// always null today. The Phase-2 pg-boss job writes a cached
// paragraph onto the workout row and maps it here — the detail page
// already composes `{aiInsight ? <card/> : null}`, so the card
// mounts with zero layout rework.
aiInsight: null,
// The per-workout Activity Insight. A pure READ of a row the
// `workout-insight-generate` worker wrote when this workout landed —
// this route never generates, never enqueues, and never falls back to
// a provider. A workout with no row (every historical one, every
// re-synced one, every one on a provider-less install) serves null and
// the page's `{aiInsight ? <card/> : null}` renders nothing.
aiInsight,
// v1.4.32 — when the requested id is a non-canonical twin the
// caller can redirect to `canonicalId` to land on the cluster
// winner. `canonicalId === id` when the requested row already
+105 -2
View File
@@ -13,11 +13,15 @@
* when the row is the canonical pick, the winner id when it is a
* non-canonical twin;
* - the optional `route` field flows through GeoJSON geometry when a
* `WorkoutRoute` row is attached, and is null when missing.
* `WorkoutRoute` row is attached, and is null when missing;
* - `aiInsight` is a pure READ: it serves a stored paragraph when one
* exists, null when none does, and never generates one.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
@@ -98,6 +102,7 @@ const BASE_ROW = {
createdAt: new Date("2026-05-15T07:30:00Z"),
updatedAt: new Date("2026-05-15T07:30:00Z"),
route: null,
insight: null,
};
describe("GET /api/workouts/{id}", () => {
@@ -321,3 +326,101 @@ describe("GET /api/workouts/{id}", () => {
]);
});
});
/**
* The Activity-Insight read.
*
* The load-bearing property is what is ABSENT: this route has no enqueue, no
* provider resolution and no fallback. A workout with no stored row serves null
* and the page renders nothing — which is every workout that predates the
* feature, every re-synced one, and every one on a provider-less install.
*/
describe("GET /api/workouts/{id} — aiInsight", () => {
// A throwaway test key. The route decrypts a stored paragraph, so the suite
// needs a configured cipher; `_resetCryptoCacheForTests` keeps the stubbed
// key from leaking into the neighbouring describes.
const TEST_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
});
async function get() {
return GET(
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
method: "GET",
}),
makeParams("w-1"),
);
}
it("serves null when the workout has no stored paragraph", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: null,
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
});
it("serves the decrypted paragraph when one exists", async () => {
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
const generatedAt = new Date("2026-05-15T07:35:00Z");
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: encryptToBytes("A steady, aerobic-leaning run."),
generatedAt,
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toEqual({
paragraph: "A steady, aerobic-leaning run.",
generatedAt: generatedAt.toISOString(),
});
});
it("degrades to no card rather than failing the page on an undecryptable row", async () => {
// `decrypt` is fail-closed by design. A key rotated away must not turn the
// whole workout-detail page into a 500 over a garnish field.
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([0, 1, 2, 3]),
generatedAt: new Date("2026-05-15T07:35:00Z"),
},
} as never);
const res = await get();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.aiInsight).toBeNull();
// The rest of the payload is untouched — the failure is scoped to the field.
expect(body.data.id).toBe("w-1");
expect(body.data.durationSec).toBe(1800);
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
});