From 0a8ccaa30c51901814e00b98854556587ae2e8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:37:13 +0200 Subject: [PATCH] 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 --- .../0175_v1186_disclaimer_ack/migration.sql | 18 ++++++ prisma/schema.prisma | 11 ++++ src/app/api/auth/me/route.ts | 4 ++ src/app/api/onboarding/disclaimer/route.ts | 61 +++++++++++++++++++ src/components/onboarding/WelcomeCarousel.tsx | 51 +++++++++++++++- src/hooks/use-auth.ts | 9 +++ src/lib/onboarding/disclaimer.ts | 10 +++ 7 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/0175_v1186_disclaimer_ack/migration.sql create mode 100644 src/app/api/onboarding/disclaimer/route.ts create mode 100644 src/lib/onboarding/disclaimer.ts diff --git a/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql new file mode 100644 index 000000000..389c580ec --- /dev/null +++ b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f87097c6e..b4de47a28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index d23f63b8e..434f29221 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts new file mode 100644 index 000000000..5422adb60 --- /dev/null +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -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 = `. 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 }); +}); diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx index 937e0fa0d..986a666f7 100644 --- a/src/components/onboarding/WelcomeCarousel.tsx +++ b/src/components/onboarding/WelcomeCarousel.tsx @@ -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(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() { + {/* 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). */} +
+ setAcknowledged(next === true)} + className="mt-0.5" + /> + +
+