release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)

* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 17:46:31 +02:00
1 parent 956d29032f
commit 643e6ec3a0
59 files changed
+7318 -3844

No files matched your search

+14 -2639
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -372,7 +372,13 @@ export const GET = apiHandler(async () => {
unit: "steps",
range: stepsRange,
classification: stepsClassification,
source: "WHO",
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/insights/prompts/{base-system,
// general-status}.ts already enforce this attribution; this
// surface label was the last "WHO" mislabel in the codebase.
source: "Saint-Maurice JAMA 2020",
});
// 8. Medication Compliance (average across active medications)
+1 -1
View File
@@ -632,7 +632,7 @@ export default function InsightsPage() {
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const w = analytics?.summaries.WEIGHT;
const w = analytics?.summaries?.WEIGHT;
const sys = analytics?.summaries.BLOOD_PRESSURE_SYS;
const dia = analytics?.summaries.BLOOD_PRESSURE_DIA;
const p = analytics?.summaries.PULSE;
+57 -49
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -130,10 +130,11 @@ export default function MedicationsPage() {
const byName = (a: Medication, b: Medication) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" });
const activeMeds = (medications?.filter((m) => m.active) ?? []).sort(byName);
const inactiveMeds = (medications?.filter((m) => !m.active) ?? []).sort(
byName,
);
// Defensive against stale service-worker responses or any future API
// shape change: only filter when we actually have an array.
const medsArray = Array.isArray(medications) ? medications : [];
const activeMeds = medsArray.filter((m) => m.active).sort(byName);
const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName);
return (
<div className="space-y-5">
@@ -485,20 +486,52 @@ function ApiEndpointDialog({
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
const [enabled, setEnabled] = useState(false);
const [activeTokenCount, setActiveTokenCount] = useState(0);
const [loadingStatus, setLoadingStatus] = useState(false);
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
const {
data: status,
isFetching: loadingStatus,
refetch: refetchStatus,
error: statusError,
} = useQuery<ApiEndpointStatus>({
queryKey: apiEndpointKey,
queryFn: async () => {
const res = await fetch(
`/api/medications/${medication!.id}/api-endpoint`,
);
const json = await res.json();
if (!res.ok) {
throw new Error(json.error || t("medications.statusLoadFailed"));
}
return {
enabled: json.data.enabled === true,
activeTokenCount: json.data.activeTokenCount ?? 0,
};
},
enabled: !!medication,
staleTime: 0,
gcTime: 0,
});
const enabled = status?.enabled ?? false;
const activeTokenCount = status?.activeTokenCount ?? 0;
const [token, setToken] = useState<string | null>(null);
const [toggling, setToggling] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [exampleType, setExampleType] = useState<ExampleType>("curl");
const displayMsg =
msg ?? (statusError instanceof Error ? statusError.message : null);
function handleClose() {
setEnabled(false);
setActiveTokenCount(0);
queryClient.removeQueries({ queryKey: apiEndpointKey });
setToken(null);
setMsg(null);
setCopied(null);
@@ -506,27 +539,6 @@ function ApiEndpointDialog({
onClose();
}
const loadStatus = useCallback(async () => {
if (!medication) return;
setLoadingStatus(true);
setMsg(null);
try {
const res = await fetch(`/api/medications/${medication.id}/api-endpoint`);
if (res.ok) {
const json = await res.json();
setEnabled(json.data.enabled === true);
setActiveTokenCount(json.data.activeTokenCount ?? 0);
} else {
const json = await res.json();
setMsg(json.error || t("medications.statusLoadFailed"));
}
} catch {
setMsg(t("medications.statusLoadFailed"));
} finally {
setLoadingStatus(false);
}
}, [medication, t]);
async function toggleEndpoint(nextEnabled: boolean) {
if (!medication) return;
setToggling(true);
@@ -547,15 +559,16 @@ function ApiEndpointDialog({
return;
}
setEnabled(json.data.enabled === true);
if (typeof json.data.activeTokenCount === "number") {
setActiveTokenCount(json.data.activeTokenCount);
} else if (
!json.data.enabled &&
typeof json.data.revokedTokenCount === "number"
) {
setActiveTokenCount(0);
}
const nextStatus: ApiEndpointStatus = {
enabled: json.data.enabled === true,
activeTokenCount:
typeof json.data.activeTokenCount === "number"
? json.data.activeTokenCount
: !json.data.enabled
? 0
: (status?.activeTokenCount ?? 0),
};
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
if (json.data.token) {
setToken(json.data.token);
@@ -579,11 +592,6 @@ function ApiEndpointDialog({
setTimeout(() => setCopied(null), 2000);
}
useEffect(() => {
if (!medication) return;
void loadStatus();
}, [medication, loadStatus]);
const baseUrl =
typeof window !== "undefined" ? window.location.origin : "https://...";
const endpoint = `${baseUrl}/api/ingest/medication`;
@@ -756,11 +764,11 @@ function ApiEndpointDialog({
)}
</div>
{msg && (
{displayMsg && (
<p
className={`text-sm ${msg === t("medications.apiEndpointActivated") || msg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
{displayMsg}
</p>
)}
@@ -780,7 +788,7 @@ function ApiEndpointDialog({
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
void loadStatus();
void refetchStatus();
}}
disabled={loadingStatus || toggling}
>
+1 -1
View File
@@ -197,7 +197,7 @@ export default function DashboardPage() {
enabled: isAuthenticated,
});
const w = data?.summaries.WEIGHT;
const w = data?.summaries?.WEIGHT;
const sys = data?.summaries.BLOOD_PRESSURE_SYS;
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
const p = data?.summaries.PULSE;