feat(onboarding): one-time medical-disclaimer acknowledgment

Replace the per-page and per-chart disclaimer banners with a single
acknowledgment at the start of onboarding: HealthLog is a private tracking
tool, not a clinical assessment or diagnosis; talk to a doctor if worried.

- record the acknowledgment per user (disclaimer_acknowledged_at/_version,
  migration 0175) mirroring the research-mode acknowledgment precedent
- gate the welcome "Get started" on the checkbox; expose the timestamp on
  /api/auth/me so a returning user is never re-asked
- keep the full legal text reachable on the public privacy page
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:37:13 +02:00
1 parent 267dcad13d
commit 0a8ccaa30c
7 files changed
+162 -2

No files matched your search

@@ -0,0 +1,18 @@
-- v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
--
-- Two nullable columns on `users` recording that the user acknowledged, once
-- at the start of onboarding, that HealthLog is a private tracking tool — not
-- a clinical assessment or diagnosis — and that they should talk to a doctor
-- if worried. The `*_version` pins exactly which copy was read (mirrors the
-- `research_mode_acknowledged_*` pair), so a future material wording change
-- can re-prompt without a schema change.
--
-- Both nullable with no default: NULL = never acknowledged. The prompt only
-- gates fresh onboarding, so existing accounts (backfilled to NULL) are never
-- re-walled. This replaces the per-page / per-chart disclaimer banners that
-- were removed app-wide in the same release; the acknowledged legal text
-- stays reachable on the public privacy page.
ALTER TABLE "users"
ADD COLUMN "disclaimer_acknowledged_at" TIMESTAMP(3),
ADD COLUMN "disclaimer_acknowledged_version" TEXT;
+11
View File
@@ -279,6 +279,17 @@ model User {
// null on a Settings replay so the tour genuinely restarts.
onboardingTourProgressJson Json? @map("onboarding_tour_progress_json")
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown
// once at the start of onboarding: HealthLog is a private tracking tool,
// not a clinical assessment or diagnosis; if worried, talk to a doctor.
// Mirrors the `researchModeAcknowledged*` pair — the version pins exactly
// which copy the user read, so a future material wording change can ask
// again. Null = never acknowledged (legacy accounts read as never-asked;
// the prompt only gates fresh onboarding, so they are not re-walled). The
// acknowledged legal text stays reachable on the public privacy page.
disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at")
disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
+4
View File
@@ -71,6 +71,10 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
// when never acknowledged; the onboarding welcome gate reads this to
// decide whether to require the acknowledgment before "Get started".
disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt,
// v1.18.6 — resumable module-tour progress. Null when the user has
// not started the tour; otherwise the resume point the launcher
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
@@ -0,0 +1,61 @@
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
*
* POST { version: string }
* → stamps `users.disclaimer_acknowledged_at = now()` +
* `disclaimer_acknowledged_version = <version>`. Idempotent: a repeat
* acknowledgment of the same version simply refreshes the timestamp.
*
* Replaces the per-page / per-chart disclaimer banners removed app-wide in
* the same release. The acknowledged legal text stays reachable on the public
* privacy page. Never auto-set; the onboarding welcome step is the one writer.
*/
const bodySchema = z.object({
// Echoed back from the client so a stale shell cannot silently record an
// acknowledgment of copy it never rendered. Bounded; the server pins the
// canonical version it persists regardless.
version: z.string().min(1).max(64),
});
export const POST = apiHandler(async (request) => {
const { user } = await requireAuth();
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = z.safeParse(bodySchema, rawBody);
if (!parsed.success) {
return apiError("Invalid input", 422);
}
// The server pins the canonical version it stamps — the body version is
// only a freshness signal, never the source of truth.
await prisma.user.update({
where: { id: user.id },
data: {
disclaimerAcknowledgedAt: new Date(),
disclaimerAcknowledgedVersion: DISCLAIMER_VERSION,
},
});
annotate({
action: { name: "onboarding.disclaimer.acknowledge" },
meta: {
version: DISCLAIMER_VERSION,
clientVersion: parsed.data.version,
},
});
return apiSuccess({ acknowledgedVersion: DISCLAIMER_VERSION });
});
+49 -2
View File
@@ -13,11 +13,16 @@ import {
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
* v1.4.25 W14b-Content — onboarding welcome carousel.
@@ -72,11 +77,19 @@ export function WelcomeCarousel() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { user } = useAuth();
const railRef = useRef<HTMLDivElement | null>(null);
const liveRegionId = useId();
const disclaimerId = useId();
const [active, setActive] = useState(0);
const [advancing, setAdvancing] = useState(false);
// v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates
// "Get started". Pre-checked for an account that already acknowledged (a
// re-walk of step 0) so a returning user is not re-asked.
const [acknowledged, setAcknowledged] = useState(
() => user?.disclaimerAcknowledgedAt != null,
);
// Track which slide is centred in the rail. We observe each slide
// with an IntersectionObserver — simpler and more reliable than
@@ -128,9 +141,16 @@ export function WelcomeCarousel() {
}, []);
async function handleGetStarted() {
if (advancing) return;
if (advancing || !acknowledged) return;
setAdvancing(true);
try {
// v1.18.6 (DISC-02) — record the one-time acknowledgment before
// advancing. Skipped when the account already acknowledged (re-walk).
if (user?.disclaimerAcknowledgedAt == null) {
await apiPost("/api/onboarding/disclaimer", {
version: DISCLAIMER_VERSION,
});
}
await apiPost("/api/onboarding/step", { step: 1 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/1");
@@ -273,12 +293,39 @@ export function WelcomeCarousel() {
</Button>
</div>
{/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This
replaces the per-page / per-chart disclaimer banners removed app-wide
in the same release; the full legal text stays reachable on the
public privacy page (link below). */}
<div className="border-border/60 bg-muted/30 flex items-start gap-3 rounded-lg border p-4">
<Checkbox
id={disclaimerId}
checked={acknowledged}
onCheckedChange={(next) => setAcknowledged(next === true)}
className="mt-0.5"
/>
<label
htmlFor={disclaimerId}
className="text-muted-foreground text-sm leading-relaxed"
>
{t("onboarding.disclaimer.acknowledge")}{" "}
<Link
href="/privacy#medical-boundary"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{t("onboarding.disclaimer.learnMore")}
</Link>
</label>
</div>
<div className="flex justify-end pt-2">
<Button
type="button"
size="lg"
onClick={handleGetStarted}
disabled={advancing}
disabled={advancing || !acknowledged}
className="min-h-11"
>
{t("onboarding.welcome.cta")}
+9
View File
@@ -43,6 +43,15 @@ export interface AuthUser {
* `onboardingTourCompleted` boolean, which stays the auto-launch gate.
*/
onboardingTourProgress: AuthTourProgress | null;
/**
* v1.18.6 (DISC-02) — ISO timestamp of the one-time medical-disclaimer
* acknowledgment, or null when never acknowledged. The onboarding welcome
* step gates "Get started" on a non-null value for a fresh account.
* Optional in the type so a stale /me payload (older server image without
* the field) and existing test fixtures coerce to "never acknowledged"
* rather than failing the shape.
*/
disclaimerAcknowledgedAt?: string | null;
/**
* v1.5.5 — relative URL of the user's self-hosted avatar, served
* from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the
+10
View File
@@ -0,0 +1,10 @@
/**
* v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment version.
*
* Pinned per the `researchModeAcknowledged*` precedent: the user row stores
* exactly which copy was acknowledged, so a future material wording change
* can bump this constant to re-prompt. Keep it in lockstep with the
* `onboarding.disclaimer.*` i18n copy — bump on a substantive text change,
* not on a typo fix.
*/
export const DISCLAIMER_VERSION = "2026-06-18";