mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user