mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui
# Conflicts: # src/__tests__/baselines/source-function-lines.json # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
879473305c
115 files changed
+5037
-599
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 91,
|
||||
"total": 101342,
|
||||
"count": 90,
|
||||
"total": 100487,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -12,7 +12,6 @@
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1086,
|
||||
"components/layout/sidebar-nav.tsx": 855,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1160,
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 355,
|
||||
"total": 114755,
|
||||
"total": 114640,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -143,10 +143,10 @@
|
||||
"components/labs/lab-list.tsx :: LabList": 308,
|
||||
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 520,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 518,
|
||||
"components/layout/bottom-nav.tsx :: BottomNav": 262,
|
||||
"components/layout/capture-picker.tsx :: CapturePicker": 235,
|
||||
"components/layout/sidebar-nav.tsx :: SidebarNav": 372,
|
||||
"components/layout/sidebar-nav.tsx :: SidebarNav": 356,
|
||||
"components/layout/top-bar.tsx :: TopBar": 212,
|
||||
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
|
||||
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
|
||||
@@ -200,7 +200,7 @@
|
||||
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280,
|
||||
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228,
|
||||
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282,
|
||||
"components/settings/api-section.tsx :: ApiTokensCard": 366,
|
||||
"components/settings/api-section.tsx :: ApiTokensCard": 269,
|
||||
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
|
||||
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
|
||||
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2898,
|
||||
"total": 9296,
|
||||
"total": 9294,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"components/layout/__tests__/shared-record-chrome.test.tsx": 1,
|
||||
"components/layout/__tests__/sidebar-nav.test.tsx": 3,
|
||||
"components/layout/account-switcher-menu.tsx": 1,
|
||||
"components/layout/auth-shell.tsx": 15,
|
||||
"components/layout/auth-shell.tsx": 14,
|
||||
"components/layout/bottom-nav.tsx": 8,
|
||||
"components/layout/capture-picker.tsx": 5,
|
||||
"components/layout/nav-model.ts": 17,
|
||||
@@ -1359,7 +1359,7 @@
|
||||
"components/settings/ai/response-timeout-card.tsx": 1,
|
||||
"components/settings/ai/shared.ts": 4,
|
||||
"components/settings/anamnesis-section.tsx": 2,
|
||||
"components/settings/api-section.tsx": 2,
|
||||
"components/settings/api-section.tsx": 1,
|
||||
"components/settings/coach-memory-section.tsx": 3,
|
||||
"components/settings/coach-nudge-card.tsx": 4,
|
||||
"components/settings/coach-prefs-section.tsx": 2,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Every write that labels a measurement `EXTERNAL` also links it to the token
|
||||
* that carried it (#1272).
|
||||
*
|
||||
* The label and the link answer one question together: whether the HealthLog
|
||||
* app writes the reading into Apple Health is the choice its person made for
|
||||
* that token (`apple-health-mirror.ts`). An EXTERNAL row written without the
|
||||
* link resolves to false forever, whatever the token says, and nothing in the
|
||||
* write path would notice. So the files that resolve `EXTERNAL_SOURCE` are an
|
||||
* inventory: a measurement writer must set `ingestTokenId` at least as often
|
||||
* as it assigns the label, and a file that starts using the constant fails
|
||||
* here until it is classified.
|
||||
*
|
||||
* A tripwire, not a proof: it counts, it does not trace data flow. The
|
||||
* behaviour is pinned by `tests/integration/apple-health-mirror.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
readSourceText,
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** Files that write measurement rows labelled with the constant. */
|
||||
const MEASUREMENT_WRITERS = [
|
||||
"lib/measurements/batch-ingest/prepare.ts",
|
||||
"lib/measurements/create/create-readings.ts",
|
||||
"lib/measurements/create/create-sleep-stage.ts",
|
||||
];
|
||||
|
||||
/** Files that use the constant for something other than a measurement write. */
|
||||
const NOT_MEASUREMENT_WRITES: Record<string, string> = {
|
||||
"app/api/tokens/measurements/route.ts":
|
||||
"counts the readings that belong to no token (a read)",
|
||||
"lib/validations/measurement.ts": "the source enum and the edit allowlist",
|
||||
"lib/workouts/batch-ingest/entries.ts":
|
||||
"labels workout rows, which carry no token link",
|
||||
};
|
||||
|
||||
function usesOfConstant(rel: string): {
|
||||
assignments: number;
|
||||
links: number;
|
||||
} {
|
||||
const code = stripComments(readSourceText(join(SRC, rel)));
|
||||
const assignments = (code.match(/\?\s*EXTERNAL_SOURCE\b/g) ?? []).length;
|
||||
// A value written under the key, or the shorthand. Not a type member
|
||||
// (`ingestTokenId: string | null`) and not a read (`writer.ingestTokenId`),
|
||||
// which write nothing.
|
||||
const links = (
|
||||
code.match(/(?<![.\w])ingestTokenId(?:\s*:(?!\s*string\b)|\s*[,}])/g) ?? []
|
||||
).length;
|
||||
return { assignments, links };
|
||||
}
|
||||
|
||||
describe("an EXTERNAL measurement carries its token", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !/\.test\.tsx?$/.test(p))
|
||||
.filter((p) => p !== "lib/measurements/external-source.ts")
|
||||
.filter((p) =>
|
||||
/\bEXTERNAL_SOURCE\b/.test(stripComments(readSourceText(join(SRC, p)))),
|
||||
);
|
||||
|
||||
it("knows every file that uses the constant", () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files).toEqual(
|
||||
[...MEASUREMENT_WRITERS, ...Object.keys(NOT_MEASUREMENT_WRITES)].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(MEASUREMENT_WRITERS)(
|
||||
"%s links the token wherever it assigns the label",
|
||||
(rel) => {
|
||||
const { assignments, links } = usesOfConstant(rel);
|
||||
expect(assignments).toBeGreaterThan(0);
|
||||
expect(links).toBeGreaterThanOrEqual(assignments);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -32,7 +32,10 @@ function code(source: string, fileName?: string): string {
|
||||
describe("the shell owns the safe area", () => {
|
||||
it("the authenticated shell wrapper takes the insets", () => {
|
||||
const shell = code(read("src/components/layout/auth-shell.tsx"));
|
||||
expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/);
|
||||
expect(shell).toMatch(/<div className=\{SHELL_ROOT_CLASS\}>/);
|
||||
expect(read("src/components/layout/shell-metrics.ts")).toMatch(
|
||||
/SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -37,6 +41,7 @@ export const GET = apiHandler(
|
||||
// is not part of a unique index.
|
||||
const measurement = await prisma.measurement.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
|
||||
if (!measurement || measurement.userId !== user.id) {
|
||||
@@ -51,7 +56,9 @@ export const GET = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
return apiSuccess(
|
||||
withAppleHealthMirror(shapeMeasurementNotes(measurement)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -113,62 +120,8 @@ export const PUT = apiHandler(
|
||||
// implausible edited value flowed unchecked into rollups, the health
|
||||
// score, the BP gates and the Coach snapshot.
|
||||
if (data.value !== undefined && data.value !== existing.value) {
|
||||
// Server-owned rows first: a value attributed to a connector / import /
|
||||
// computed engine is the provider's reading — editing the number would
|
||||
// forge a source-attributed row the server never received. Timestamp
|
||||
// and note edits stay allowed — annotating a Withings reading is
|
||||
// legitimate.
|
||||
//
|
||||
// v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than
|
||||
// the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared,
|
||||
// because `EXTERNAL` separates the questions. A client may not NAME that
|
||||
// source (it is resolved from the ingest credential, and the write
|
||||
// allowlist is published as `ingest.writeAllowlist`), but the hardware
|
||||
// behind the token is the user's own scale, so the rationale above —
|
||||
// "the value is the provider's" — does not reach it. Locking those rows
|
||||
// would also break the correctability the settings card and the Home
|
||||
// Assistant guide promise. This is the only site that reads the wider
|
||||
// set; re-merging the two constants silently value-locks bridge rows.
|
||||
if (
|
||||
!(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes(
|
||||
existing.source,
|
||||
)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.server-owned-source" },
|
||||
meta: { measurement_id: id, source: existing.source },
|
||||
});
|
||||
return apiError(
|
||||
"Values from a connected source cannot be edited",
|
||||
409,
|
||||
{ errorCode: "measurement.update.server_owned_source" },
|
||||
);
|
||||
}
|
||||
|
||||
// Range check against the row's OWN type (the edit body carries no
|
||||
// type). Returned through the standard multi-issue 422 envelope so the
|
||||
// edit sheet renders it like any other field error.
|
||||
const rangeCheck = z
|
||||
.object({
|
||||
value: z.number().superRefine((value, ctx) => {
|
||||
const rangeError = validateMeasurementRange(existing.type, value);
|
||||
if (rangeError) {
|
||||
ctx.addIssue({ code: "custom", message: rangeError });
|
||||
}
|
||||
}),
|
||||
})
|
||||
.safeParse({ value: data.value });
|
||||
if (!rangeCheck.success) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.validation-failed" },
|
||||
meta: {
|
||||
issue_count: rangeCheck.error.issues.length,
|
||||
measurement_id: id,
|
||||
reason: "value_out_of_range",
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(rangeCheck.error, 422);
|
||||
}
|
||||
const refused = refuseValueEdit(existing, data.value, id);
|
||||
if (refused) return refused;
|
||||
}
|
||||
|
||||
let measurement;
|
||||
@@ -185,6 +138,7 @@ export const PUT = apiHandler(
|
||||
notesEncrypted: encryptNote(data.notes),
|
||||
}),
|
||||
},
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
} catch (err) {
|
||||
// v1.4.28 — re-pointing `measuredAt` onto an existing
|
||||
@@ -252,7 +206,9 @@ export const PUT = apiHandler(
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
return apiSuccess(
|
||||
withAppleHealthMirror(shapeMeasurementNotes(measurement)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -320,3 +276,69 @@ export const DELETE = apiHandler(
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a changed value may be written over `existing`, as the refusal to
|
||||
* answer when it may not; null when it may.
|
||||
*/
|
||||
function refuseValueEdit(
|
||||
existing: { source: string; type: MeasurementType },
|
||||
value: number,
|
||||
id: string,
|
||||
): Response | null {
|
||||
// Server-owned rows first: a value attributed to a connector / import /
|
||||
// computed engine is the provider's reading — editing the number would
|
||||
// forge a source-attributed row the server never received. Timestamp
|
||||
// and note edits stay allowed — annotating a Withings reading is
|
||||
// legitimate.
|
||||
//
|
||||
// v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than
|
||||
// the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared,
|
||||
// because `EXTERNAL` separates the questions. A client may not NAME that
|
||||
// source (it is resolved from the ingest credential, and the write
|
||||
// allowlist is published as `ingest.writeAllowlist`), but the hardware
|
||||
// behind the token is the user's own scale, so the rationale above —
|
||||
// "the value is the provider's" — does not reach it. Locking those rows
|
||||
// would also break the correctability the settings card and the Home
|
||||
// Assistant guide promise. This is the only site that reads the wider
|
||||
// set; re-merging the two constants silently value-locks bridge rows.
|
||||
if (
|
||||
!(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes(
|
||||
existing.source,
|
||||
)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.server-owned-source" },
|
||||
meta: { measurement_id: id, source: existing.source },
|
||||
});
|
||||
return apiError("Values from a connected source cannot be edited", 409, {
|
||||
errorCode: "measurement.update.server_owned_source",
|
||||
});
|
||||
}
|
||||
|
||||
// Range check against the row's OWN type (the edit body carries no
|
||||
// type). Returned through the standard multi-issue 422 envelope so the
|
||||
// edit sheet renders it like any other field error.
|
||||
const rangeCheck = z
|
||||
.object({
|
||||
value: z.number().superRefine((value, ctx) => {
|
||||
const rangeError = validateMeasurementRange(existing.type, value);
|
||||
if (rangeError) {
|
||||
ctx.addIssue({ code: "custom", message: rangeError });
|
||||
}
|
||||
}),
|
||||
})
|
||||
.safeParse({ value: value });
|
||||
if (!rangeCheck.success) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.validation-failed" },
|
||||
meta: {
|
||||
issue_count: rangeCheck.error.issues.length,
|
||||
measurement_id: id,
|
||||
reason: "value_out_of_range",
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(rangeCheck.error, 422);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -261,6 +261,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const result = await ingestMeasurementBatch({
|
||||
user: { id: user.id, timezone: user.timezone },
|
||||
scoped,
|
||||
// On the Bearer path the token's id rides `session.id`.
|
||||
ingestTokenId: scoped ? auth.session.id : null,
|
||||
entries,
|
||||
syncTrigger: parsed.data.syncTrigger,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from "@/lib/validations/measurement";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
type MeasurementWithMirrorInput,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { measurementListResponse } from "@/lib/measurements/list/measurement-list";
|
||||
import {
|
||||
createReading,
|
||||
@@ -154,6 +158,8 @@ async function postMeasurement(request: NextRequest) {
|
||||
const writer: ReadingWriter = {
|
||||
user: { id: user.id, timezone: user.timezone },
|
||||
scoped,
|
||||
// On the Bearer path the token's id rides `session.id`.
|
||||
ingestTokenId: scoped ? auth.session.id : null,
|
||||
ipAddress: getClientIp(request),
|
||||
};
|
||||
|
||||
@@ -202,7 +208,7 @@ async function postMeasurement(request: NextRequest) {
|
||||
if ("duplicate" in created) {
|
||||
return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META);
|
||||
}
|
||||
return apiSuccess(created.rows.map(shapeMeasurementNotes), 201);
|
||||
return apiSuccess(created.rows.map(answerRow), 201);
|
||||
}
|
||||
|
||||
// Single mode (existing behavior)
|
||||
@@ -237,7 +243,12 @@ async function postMeasurement(request: NextRequest) {
|
||||
if ("duplicate" in created) {
|
||||
return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META);
|
||||
}
|
||||
return apiSuccess(shapeMeasurementNotes(created.row), 201);
|
||||
return apiSuccess(answerRow(created.row), 201);
|
||||
}
|
||||
|
||||
/** A written row as it is answered: the note readable, the write-back resolved. */
|
||||
function answerRow(row: MeasurementWithMirrorInput) {
|
||||
return withAppleHealthMirror(shapeMeasurementNotes(row));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +273,7 @@ async function postNamedSleepStage(
|
||||
{
|
||||
userId: writer.user.id,
|
||||
scoped: writer.scoped,
|
||||
ingestTokenId: writer.ingestTokenId,
|
||||
ipAddress: writer.ipAddress,
|
||||
},
|
||||
parsed.data,
|
||||
|
||||
@@ -106,22 +106,25 @@ beforeEach(() => {
|
||||
} as never);
|
||||
// Non-cycle domains always return rows so a 403/empty-everything result
|
||||
// cannot be mistaken for a passing "cycle omitted" assertion.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
externalId: "ext-m-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: NOW,
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 1,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
] as never);
|
||||
// The `measurementMirror` walk reads measurements too (the readings of
|
||||
// tokens whose Apple Health setting changed); it finds none here.
|
||||
const measurementRow = {
|
||||
id: "m-1",
|
||||
externalId: "ext-m-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: NOW,
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 1,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
|
||||
where?: { ingestToken?: unknown };
|
||||
}) => (args.where?.ingestToken ? [] : [measurementRow])) as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { updateMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
import { setMeasurementTokenMirror } from "@/lib/measurements/token-mirror";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -38,3 +42,60 @@ export const DELETE = apiHandler(
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Change a measurement token's one setting: whether the HealthLog app writes
|
||||
* its readings into Apple Health (#1272).
|
||||
*
|
||||
* - Cookie only. A Bearer, the token's own included, cannot change what its
|
||||
* readings are allowed to become; the person decides, from the browser
|
||||
* they are sitting at.
|
||||
* - The caller's own tokens only: another account's answers 404.
|
||||
* - Measurement tokens only. A wildcard, an MCP or any other narrow token has
|
||||
* no such setting and is refused with 422 `tokens.mirror.not_measurement_token`.
|
||||
* - A revoked token is accepted: its readings stay in the record.
|
||||
*
|
||||
* Not gated on the instance-wide API switch: no credential is created or
|
||||
* extended, and the readings already in the record are what it governs.
|
||||
*/
|
||||
export const PATCH = apiHandler(
|
||||
async (request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
annotate({ action: { name: "tokens.measurements.mirror.update" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`tokens:mirror:${user.id}`,
|
||||
MIRROR_RATE_LIMIT_MAX,
|
||||
MIRROR_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many changes, try again later", 429);
|
||||
}
|
||||
|
||||
const parsed = await parseJsonBody(request, updateMeasurementTokenSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { id } = await params;
|
||||
const outcome = await setMeasurementTokenMirror({
|
||||
userId: user.id,
|
||||
tokenId: id,
|
||||
mirrorToAppleHealth: parsed.data.mirrorToAppleHealth,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
if (outcome.kind === "not_found") {
|
||||
return apiError("Token not found", 404);
|
||||
}
|
||||
if (outcome.kind === "not_measurement_token") {
|
||||
return apiError("Only a measurement token has this setting", 422, {
|
||||
errorCode: "tokens.mirror.not_measurement_token",
|
||||
});
|
||||
}
|
||||
return apiSuccess({ id, mirrorToAppleHealth: outcome.mirrorToAppleHealth });
|
||||
},
|
||||
);
|
||||
|
||||
/** Changes per account per window: a settings switch, not an API. */
|
||||
const MIRROR_RATE_LIMIT_MAX = 30;
|
||||
const MIRROR_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
@@ -39,10 +39,10 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { apiToken: { count: vi.fn() } },
|
||||
prisma: { apiToken: { count: vi.fn() }, measurement: { count: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { GET, POST } from "../route";
|
||||
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
@@ -147,6 +147,27 @@ describe("what it mints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the write-back off unless asked, and refuses a non-boolean", async () => {
|
||||
const mint = (body: unknown) =>
|
||||
POST(req(body) as Parameters<typeof POST>[0]);
|
||||
await mint({ name: "Scale" });
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].mirrorToAppleHealth).toBe(
|
||||
false,
|
||||
);
|
||||
await mint({ name: "Scale", mirrorToAppleHealth: true });
|
||||
await mint({ name: "Scale", mirrorToAppleHealth: "yes" });
|
||||
expect(issueApiToken).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[1][0].mirrorToAppleHealth).toBe(
|
||||
true,
|
||||
);
|
||||
expect(auditLog).toHaveBeenLastCalledWith(
|
||||
"tokens.measurements.create",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ mirrorToAppleHealth: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts only live tokens carrying this scope", async () => {
|
||||
// The whole shape of the query, asserted rather than assumed. Counting
|
||||
// every row would let the `["*"]` tokens a login mints consume the budget
|
||||
@@ -239,3 +260,20 @@ describe("when it refuses", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the readings no token reaches", () => {
|
||||
it("counts the caller's live EXTERNAL readings without a token", async () => {
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(4);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({ unattributedReadings: 4 });
|
||||
expect(prisma.measurement.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: USER.id,
|
||||
source: "EXTERNAL",
|
||||
ingestTokenId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,8 @@
|
||||
* operate should not include making more credentials.
|
||||
*
|
||||
* No listing leg. `/api/tokens` already returns every token with its
|
||||
* `permissions`, so the settings card filters that one query rather than
|
||||
* duplicating it — the MCP module needs its own list only because it must hide
|
||||
* `permissions` (and a measurement token's Apple Health setting), so the
|
||||
* settings card filters that one query rather than duplicating it — the MCP module needs its own list only because it must hide
|
||||
* transient OAuth access rows, and there is no such row here. No revoke-all
|
||||
* either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The
|
||||
* per-medication toggle's bulk revoke exists because that surface is a boolean
|
||||
@@ -44,6 +44,7 @@ import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,28 @@ import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
*/
|
||||
const DEFAULT_EXPIRY_DAYS = 365;
|
||||
|
||||
/**
|
||||
* What the token list cannot say: how many of the account's EXTERNAL readings
|
||||
* belong to no token (#1272). They were sent before readings were linked to
|
||||
* the token that carried them, by an account holding more than one token, so
|
||||
* no token's Apple Health setting reaches them and they stay out of Apple
|
||||
* Health. The settings card shows a notice when the count is not zero.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
annotate({ action: { name: "tokens.measurements.summary" } });
|
||||
const unattributedReadings = await prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: EXTERNAL_SOURCE,
|
||||
ingestTokenId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
annotate({ meta: { unattributed_readings: unattributedReadings } });
|
||||
return apiSuccess({ unattributedReadings });
|
||||
});
|
||||
|
||||
/** Mints per user per minute. A credential mint is worth its own bucket. */
|
||||
const MINT_RATE_LIMIT_MAX = 10;
|
||||
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
@@ -177,12 +200,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// which is why the unit suite asserts this array and not merely the 201.
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
|
||||
mirrorToAppleHealth: parsed.data.mirrorToAppleHealth === true,
|
||||
});
|
||||
|
||||
await auditLog("tokens.measurements.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
mirrorToAppleHealth: parsed.data.mirrorToAppleHealth === true,
|
||||
},
|
||||
});
|
||||
|
||||
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
|
||||
|
||||
@@ -46,6 +46,9 @@ export const GET = apiHandler(async () => {
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revoked: true,
|
||||
// A measurement token's Apple Health write-back (#1272); false for
|
||||
// every other token.
|
||||
mirrorToAppleHealth: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
@@ -629,6 +629,28 @@ body,
|
||||
html[data-scroll="body"] {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
/*
|
||||
* The app shell (`data-scroll="shell"`, also set by `AuthShell`) takes the
|
||||
* window's edges and scrolls inside `<main>`, so the document has nothing
|
||||
* to scroll and must not bounce either. On iOS a swipe that reached the end
|
||||
* of `<main>` or of the rail chained to the document, the whole page
|
||||
* rubber-banded and showed the page colour under the shell, and the next
|
||||
* swipe went to that bounce instead of the list under the finger: the
|
||||
* "sometimes it does not scroll" a tablet reported.
|
||||
*
|
||||
* `height: 100%` gives the body the window's box. The shell is
|
||||
* `absolute inset-0`, which resolves against the initial containing block
|
||||
* while the body is static, and against the body once a dialog's scroll
|
||||
* lock makes it `position: relative` (react-remove-scroll does); an
|
||||
* auto-height body is 0 px tall and the shell would collapse behind every
|
||||
* dialog.
|
||||
*/
|
||||
html[data-scroll="shell"],
|
||||
html[data-scroll="shell"] body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The authenticated shell's geometry decisions, which no browser under test
|
||||
* can show going wrong: Playwright's WebKit and Chromium have no floating
|
||||
* toolbar, no rotation and no home indicator, so `100dvh` always equals the
|
||||
* window there. On an iPad it does not, and that disagreement was the strip
|
||||
* under the shell and the cut-off card. These hold the decisions that take
|
||||
* the disagreement out; `e2e/tablet-shell.spec.ts` measures the result.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import {
|
||||
MAIN_SCROLL_CLASS,
|
||||
SHELL_ROOT_CLASS,
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
} from "../shell-metrics";
|
||||
|
||||
const shellSource = () =>
|
||||
readSource("src/components/layout/auth-shell.tsx", [
|
||||
"SHELL_ROOT_CLASS",
|
||||
'data-slot="main-content-wrapper"',
|
||||
]);
|
||||
/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */
|
||||
const MAIN_CONTENT_WRAPPER_CLASS =
|
||||
/"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1];
|
||||
const classes = (value: string) => value.split(/\s+/).filter(Boolean);
|
||||
|
||||
/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */
|
||||
function rem(token: string): number {
|
||||
const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token);
|
||||
if (calc) return Number(calc[1]);
|
||||
const step = /-(\d+(?:\.\d+)?)$/.exec(token);
|
||||
if (step) return Number(step[1]) / 4;
|
||||
throw new Error(`no length in ${token}`);
|
||||
}
|
||||
|
||||
describe("the shell takes the window's edges", () => {
|
||||
it("takes its height from the initial containing block, never from a viewport unit", () => {
|
||||
const shell = classes(SHELL_ROOT_CLASS);
|
||||
expect(shell).toEqual(
|
||||
expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]),
|
||||
);
|
||||
// `fixed` re-rasterises the whole app in its own layer and traps the
|
||||
// bottom bar's z-index in a stacking context.
|
||||
expect(shell).not.toContain("fixed");
|
||||
expect(SHELL_ROOT_CLASS).not.toMatch(
|
||||
/\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/,
|
||||
);
|
||||
expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/);
|
||||
});
|
||||
|
||||
it("AuthShell renders that box and flags the document for it", () => {
|
||||
const source = shellSource();
|
||||
expect(source).toContain("<div className={SHELL_ROOT_CLASS}>");
|
||||
expect(source).not.toMatch(/shell-safe-area flex h-dvh/);
|
||||
expect(source).toMatch(
|
||||
/useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/,
|
||||
);
|
||||
const css = readSource("src/app/globals.css", '[data-scroll="shell"]');
|
||||
expect(css).toMatch(
|
||||
/html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bottom edge is cleared in both shells", () => {
|
||||
const fab = readSource(
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
'data-slot="coach-fab"',
|
||||
);
|
||||
const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]);
|
||||
const phoneFabBottom = rem(
|
||||
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const desktopFabBottom = rem(
|
||||
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const wrapper = classes(MAIN_CONTENT_WRAPPER_CLASS);
|
||||
const main = classes(MAIN_SCROLL_CLASS);
|
||||
|
||||
it("the desktop shell's main keeps the home indicator off the last line", () => {
|
||||
expect(main).not.toContain("shell-desktop:pb-0");
|
||||
expect(main).toContain(
|
||||
"shell-desktop:pb-[env(safe-area-inset-bottom,0px)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("the last line scrolls clear of the Coach button in the phone shell", () => {
|
||||
const mainPad = Number(
|
||||
/^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec(
|
||||
main.find((c) => c.startsWith("pb-[calc("))!,
|
||||
)![1],
|
||||
);
|
||||
const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!);
|
||||
expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize);
|
||||
});
|
||||
|
||||
it("the last line scrolls clear of the Coach button in the desktop shell", () => {
|
||||
const wrapperPad = rem(
|
||||
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
|
||||
);
|
||||
expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize);
|
||||
});
|
||||
|
||||
it("the sub-shell floor follows the desktop shell's padding", () => {
|
||||
// Top bar 4rem + pt-6 + the wrapper's bottom padding.
|
||||
const desktopPad = rem(
|
||||
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
|
||||
);
|
||||
expect(SUB_SHELL_GRID_FLOOR).toContain(
|
||||
`shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
readSidebarCollapsedPref,
|
||||
writeSidebarCollapsedPref,
|
||||
} from "../sidebar-nav";
|
||||
import { RAIL_NAV_SCROLL, RailScrollCue } from "../sidebar-nav-scroll";
|
||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
||||
import { visibleUtilityDestinations } from "../nav-model";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
@@ -577,3 +578,64 @@ describe("<SidebarNav> carries no rules for a viewport it never shows on", () =>
|
||||
expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> keeps every destination reachable on a touch tablet", () => {
|
||||
function navClass(html: string): string {
|
||||
return (
|
||||
/<nav[^>]*aria-label="[^"]*"[^>]*class="([^"]*)"/.exec(html)?.[1] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
it("scrolls the list inside the rail and keeps the swipe there", () => {
|
||||
const nav = navClass(render()).replaceAll("&", "&").split(" ");
|
||||
expect(RAIL_NAV_SCROLL.split(" ")).toEqual(
|
||||
expect.arrayContaining(["min-h-0", "flex-1", "overflow-y-auto"]),
|
||||
);
|
||||
for (const cls of RAIL_NAV_SCROLL.split(" ")) expect(nav).toContain(cls);
|
||||
// Without it a swipe at either end chained to the document on iOS.
|
||||
expect(nav).toContain("pointer-coarse:overscroll-contain");
|
||||
// A focused row lands clear of the 44 px cue under a finger.
|
||||
expect(nav).toContain("pointer-coarse:scroll-py-12");
|
||||
});
|
||||
|
||||
it("gives the rail the home indicator's inset, so the avatar row stays clear", () => {
|
||||
const aside = /<aside[^>]*class="([^"]*)"/.exec(render())?.[1] ?? "";
|
||||
expect(aside.split(" ")).toContain("pb-[env(safe-area-inset-bottom,0px)]");
|
||||
});
|
||||
|
||||
it("draws the scroll cue for a finger only, outside the focus order and the accessibility tree", () => {
|
||||
for (const direction of ["up", "down"] as const) {
|
||||
const html = renderToStaticMarkup(
|
||||
<RailScrollCue direction={direction} navRef={{ current: null }} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="sidebar-nav-scroll-cue"');
|
||||
expect(html).toContain(`data-direction="${direction}"`);
|
||||
expect(html).toContain('tabindex="-1"');
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
const cls = /class="([^"]*)"/.exec(html)![1].split(" ");
|
||||
expect(cls).toEqual(
|
||||
expect.arrayContaining(["hidden", "pointer-coarse:flex"]),
|
||||
);
|
||||
// 44 px touch floor.
|
||||
expect(cls).toContain("h-11");
|
||||
}
|
||||
});
|
||||
|
||||
it("scrolls the list most of a screen on per tap", () => {
|
||||
const calls: ScrollToOptions[] = [];
|
||||
const nav = {
|
||||
clientHeight: 400,
|
||||
scrollBy: (o: ScrollToOptions) => calls.push(o),
|
||||
} as unknown as HTMLElement;
|
||||
vi.stubGlobal("window", {
|
||||
matchMedia: () => ({ matches: true }),
|
||||
});
|
||||
const element = RailScrollCue({
|
||||
direction: "down",
|
||||
navRef: { current: nav },
|
||||
}) as React.ReactElement<{ onClick: () => void }>;
|
||||
element.props.onClick();
|
||||
vi.unstubAllGlobals();
|
||||
expect(calls).toEqual([{ top: 300, behavior: "auto" }]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from "@/components/layout/module-page-gate";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isFullBleedPage } from "./full-bleed";
|
||||
import { useDocumentScrollMode } from "./document-scroll-mode";
|
||||
import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics";
|
||||
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
|
||||
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
@@ -166,25 +168,18 @@ export function AuthShell({
|
||||
const isOnboardingPage = isOnboardingPathname(pathname);
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
|
||||
// only for the body-scrolled shells (login / register / onboarding /
|
||||
// standalone legal pages). The authenticated branch is height-locked and
|
||||
// scrolls inside its own `<main>`, which reserves its own gutter; applying
|
||||
// the document gutter there too produced a second, never-painted gutter on
|
||||
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
|
||||
// so the CSS rule scopes to them.
|
||||
// Who scrolls the page, on `<html>` (`document-scroll-mode.ts`).
|
||||
const isBodyScrolled = isPublicPage || isOnboardingPage;
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isBodyScrolled) {
|
||||
root.setAttribute("data-scroll", "body");
|
||||
} else {
|
||||
root.removeAttribute("data-scroll");
|
||||
}
|
||||
return () => {
|
||||
root.removeAttribute("data-scroll");
|
||||
};
|
||||
}, [isBodyScrolled]);
|
||||
const rendersAppShell =
|
||||
!isBodyScrolled &&
|
||||
!accessRefused &&
|
||||
!isLoading &&
|
||||
isAuthenticated &&
|
||||
recordSessionTransition.phase === "ready" &&
|
||||
!recordSessionPending;
|
||||
useDocumentScrollMode(
|
||||
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
|
||||
);
|
||||
|
||||
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
|
||||
// mounted across every route change, so its scroll offset survives
|
||||
@@ -438,9 +433,13 @@ export function AuthShell({
|
||||
five pixels of content box and pushed the logo and the account menu
|
||||
across its bottom border. `shell-safe-area` paints the strip behind
|
||||
the status bar in the chrome colour (globals.css). The bottom inset
|
||||
stays with the bottom nav and `<main>`, which own that edge.
|
||||
stays with the bottom nav, `<main>` and the rail, which own that edge.
|
||||
|
||||
The shell takes the window's edges rather than `h-dvh`, and the
|
||||
document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in
|
||||
`shell-metrics.ts` says why.
|
||||
*/}
|
||||
<div className="shell-safe-area flex h-dvh flex-col">
|
||||
<div className={SHELL_ROOT_CLASS}>
|
||||
{/*
|
||||
v1.4.43 — `<OfflineBanner>` paints only when
|
||||
`navigator.onLine === false`. Sits above the maintainership
|
||||
@@ -500,7 +499,7 @@ export function AuthShell({
|
||||
// for all of them at once instead of asking each new
|
||||
// absolutely-positioned child to remember its own wrapper.
|
||||
className={cn(
|
||||
"shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
|
||||
MAIN_SCROLL_CLASS,
|
||||
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
|
||||
)}
|
||||
>
|
||||
@@ -516,7 +515,8 @@ export function AuthShell({
|
||||
v1.16.8 — the bottom padding clears the always-on Coach
|
||||
FAB (48 px button + its offset above the bottom-nav band /
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
can always scroll out from under the floating button; the
|
||||
desktop shell's sits 5.5rem up, so `pb-24` there.
|
||||
*/}
|
||||
<div
|
||||
data-slot="main-content-wrapper"
|
||||
@@ -524,7 +524,7 @@ export function AuthShell({
|
||||
className={
|
||||
fullBleed
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
: "shell-desktop:pb-24 mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
}
|
||||
>
|
||||
{outsideSharedRecord ? (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Who scrolls the page, published on `<html>` as `data-scroll` for the rules
|
||||
* in `globals.css`.
|
||||
*
|
||||
* - `"body"`: the body-scrolled shells (login / register / onboarding /
|
||||
* standalone legal pages), where the document itself overflows. They get
|
||||
* the document-level `scrollbar-gutter`. The authenticated branch
|
||||
* scrolls inside its own `<main>`, which reserves its own gutter; the
|
||||
* document gutter there too produced a second, never-painted gutter on
|
||||
* classic-scrollbar platforms.
|
||||
* - `"shell"`: the app shell. It takes the window's edges and `<main>` is its
|
||||
* one vertical scroller, so the document gets no scroll and no rubber band
|
||||
* of its own. On iOS a bounce of the document held the next swipe, and the
|
||||
* list under the finger did not move.
|
||||
* - `null`: anything else (the refusal panel, the loading states) keeps the
|
||||
* browser's defaults.
|
||||
*/
|
||||
export type DocumentScrollMode = "body" | "shell" | null;
|
||||
|
||||
export function useDocumentScrollMode(mode: DocumentScrollMode): void {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (mode) {
|
||||
root.setAttribute("data-scroll", mode);
|
||||
} else {
|
||||
root.removeAttribute("data-scroll");
|
||||
}
|
||||
return () => {
|
||||
root.removeAttribute("data-scroll");
|
||||
};
|
||||
}, [mode]);
|
||||
}
|
||||
@@ -18,15 +18,51 @@
|
||||
* never drift — Admin previously carried a stale, all-breakpoint
|
||||
* `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires.
|
||||
*
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20`
|
||||
* (5rem) = 10.5rem. Retune here — and only here — if those shell paddings
|
||||
* change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom
|
||||
* padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) =
|
||||
* 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here —
|
||||
* and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* a locale/offline/demo banner or an iOS PWA safe-area inset makes it
|
||||
* over-reserve. A fully self-accounting `min-h-full` chain would need the
|
||||
* AuthShell content wrapper to pass a definite height through, which is out of
|
||||
* scope for the shell-only over-scroll fix.)
|
||||
*/
|
||||
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
|
||||
export const SUB_SHELL_GRID_FLOOR =
|
||||
"md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]";
|
||||
|
||||
/**
|
||||
* The authenticated shell's outer box. It takes the window's edges
|
||||
* (`absolute inset-0` against the initial containing block) rather than
|
||||
* `h-dvh`, and the top and side safe-area insets (`shell-safe-area`,
|
||||
* globals.css); the bottom inset belongs to whatever owns the bottom edge in
|
||||
* each shell.
|
||||
*
|
||||
* Not a viewport unit: `h-dvh` is the browser's idea of the visible height,
|
||||
* and on iPadOS Safari that idea and the area it lays out disagree after a
|
||||
* rotation and under the floating toolbars. A short value left a strip of
|
||||
* page colour under the shell with the last card cut off above it; a tall one
|
||||
* made the document scroll by the difference, and that document scroll took
|
||||
* the swipes meant for `<main>`. The initial containing block is the layout
|
||||
* itself, recomputed with every rotation and resize, and the document never
|
||||
* scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box
|
||||
* cannot drift from the window either.
|
||||
*
|
||||
* Not `fixed`: a fixed box gets a compositing layer of its own, which
|
||||
* re-rasterised the text of every page (the antialiasing moved on every
|
||||
* screen), and forms a stacking context that would trap the bottom bar's
|
||||
* z-index under the Coach button and the toasts.
|
||||
*/
|
||||
export const SHELL_ROOT_CLASS =
|
||||
"shell-safe-area absolute inset-0 flex flex-col";
|
||||
|
||||
/**
|
||||
* `<main>`, the shell's one vertical scroller. Phone shell: it clears the
|
||||
* bottom nav and the home indicator. Desktop shell: there is no bar, and it
|
||||
* clears the home indicator of an installed app on a tablet (zero in a
|
||||
* browser tab), so the last line never sits under it.
|
||||
*/
|
||||
export const MAIN_SCROLL_CLASS =
|
||||
"relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]";
|
||||
|
||||
/**
|
||||
* The app-chrome header band: one 4rem strip whose bottom border draws the
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The rail's destination list as a scroll region: the one part of the rail
|
||||
* that gives up height, between the pinned logo band above and the pinned
|
||||
* footer group and account row below (`sidebar-nav.tsx`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The nav list's scroll region. `scroll-py-6` keeps a focused row clear of
|
||||
* the edge fades; under a finger `scroll-py-12` keeps it clear of the scroll
|
||||
* cue as well, which is 44 px tall. `overscroll-contain` is the own-scroll
|
||||
* list rule (ui-standards §2), taken under a finger, where the swipe that
|
||||
* reaches an end is the problem. A mouse or trackpad has nothing to chain
|
||||
* to (the app shell's document neither scrolls nor bounces), and on Chromium
|
||||
* the property moves the list into a layer of its own, which re-rasterised
|
||||
* every label in the rail by a shade.
|
||||
*/
|
||||
export const RAIL_NAV_SCROLL =
|
||||
"min-h-0 flex-1 scroll-py-6 pointer-coarse:scroll-py-12 overflow-y-auto pointer-coarse:overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
|
||||
|
||||
/**
|
||||
* The scroll cue at an edge of the nav list that has more destinations
|
||||
* beyond it, under a finger only.
|
||||
*
|
||||
* A touch tablet keeps the 44 px rows, so in landscape (an iPad is 670 to
|
||||
* 830 px tall in Safari) three to seven destinations sit below the fold, and
|
||||
* the list has no scrollbar: iOS paints none on an element at rest. The fade
|
||||
* alone did not read as "swipe here", and the rail looked as if it simply
|
||||
* ended. The cue says so and is a way there in itself: a tap scrolls the
|
||||
* list most of a screen on.
|
||||
*
|
||||
* It is a pointer shortcut and nothing else. The keyboard reaches every row
|
||||
* by Tab (focus scrolls the row into view) and a screen reader reads the
|
||||
* list whole, so the cue stays out of the focus order and the accessibility
|
||||
* tree. A mouse scrolls with the wheel and never sees it.
|
||||
*/
|
||||
export function RailScrollCue({
|
||||
direction,
|
||||
navRef,
|
||||
}: {
|
||||
direction: "up" | "down";
|
||||
navRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const Icon = direction === "down" ? ChevronDown : ChevronUp;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
data-slot="sidebar-nav-scroll-cue"
|
||||
data-direction={direction}
|
||||
onClick={() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) return;
|
||||
const reduce = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
nav.scrollBy({
|
||||
top: (direction === "down" ? 1 : -1) * nav.clientHeight * 0.75,
|
||||
behavior: reduce ? "auto" : "smooth",
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"absolute inset-x-0 z-10 hidden h-11 justify-center outline-none pointer-coarse:flex",
|
||||
direction === "down"
|
||||
? "bottom-0 items-end pb-1"
|
||||
: "top-0 items-start pt-1",
|
||||
)}
|
||||
>
|
||||
<span className="bg-sidebar border-sidebar-border text-foreground flex size-7 items-center justify-center rounded-full border shadow-sm">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Height of the fade at a scroll edge that has more content beyond it. */
|
||||
const RAIL_FADE = "1.5rem";
|
||||
|
||||
/**
|
||||
* The fade mask for the list's scroll edges: a top fade while there is
|
||||
* content above, a bottom fade while there is content below. A mask paints
|
||||
* alpha only, so it reads the same in light and dark and never moves layout.
|
||||
*/
|
||||
function railFadeMask(
|
||||
top: boolean,
|
||||
bottom: boolean,
|
||||
): React.CSSProperties | undefined {
|
||||
if (!top && !bottom) return undefined;
|
||||
const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`;
|
||||
return { maskImage: image, WebkitMaskImage: image };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the nav list is taller than the room the rail leaves it, i.e.
|
||||
* whether it scrolls, and which of its edges have more content beyond them.
|
||||
* Re-measured when the nav (viewport height) or the list (entries arriving
|
||||
* as modules resolve) changes size, and on scroll.
|
||||
*/
|
||||
export function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
|
||||
overflows: boolean;
|
||||
moreAbove: boolean;
|
||||
moreBelow: boolean;
|
||||
} {
|
||||
const [edges, setEdges] = useState({
|
||||
overflows: false,
|
||||
moreAbove: false,
|
||||
moreBelow: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav || typeof ResizeObserver === "undefined") return;
|
||||
// Strict: any overflow, the same test that makes the list scroll.
|
||||
const update = () => {
|
||||
const overflows = nav.scrollHeight > nav.clientHeight;
|
||||
const moreAbove = overflows && nav.scrollTop > 0.5;
|
||||
const moreBelow =
|
||||
overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5;
|
||||
setEdges((prev) =>
|
||||
prev.overflows === overflows &&
|
||||
prev.moreAbove === moreAbove &&
|
||||
prev.moreBelow === moreBelow
|
||||
? prev
|
||||
: { overflows, moreAbove, moreBelow },
|
||||
);
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(nav);
|
||||
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
|
||||
nav.addEventListener("scroll", update, { passive: true });
|
||||
update();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
nav.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [navRef]);
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's region: the `<nav>` that scrolls, with its edge fades, and the
|
||||
* touch cues over its edges.
|
||||
*
|
||||
* The list scrolls without a native scrollbar (a classic scrollbar with
|
||||
* arrow buttons read as a foreign body in the rail); fades at the edges say
|
||||
* there is more, and focus keeps the focused row clear of them.
|
||||
* `overscroll-contain` (under a finger) keeps a swipe that reaches either end
|
||||
* inside the rail: on iOS it chained to the document, which rubber-banded and
|
||||
* held the next swipe, so the list seemed not to scroll.
|
||||
*/
|
||||
export function RailNavList({
|
||||
navRef,
|
||||
edges,
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
navRef: React.RefObject<HTMLElement | null>;
|
||||
edges: { moreAbove: boolean; moreBelow: boolean };
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-nav-region"
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={label}
|
||||
className={cn(RAIL_NAV_SCROLL, className)}
|
||||
style={railFadeMask(edges.moreAbove, edges.moreBelow)}
|
||||
data-fade-top={edges.moreAbove ? "true" : undefined}
|
||||
data-fade-bottom={edges.moreBelow ? "true" : undefined}
|
||||
onFocus={(event) => {
|
||||
event.target.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
{edges.moreAbove && <RailScrollCue direction="up" navRef={navRef} />}
|
||||
{edges.moreBelow && <RailScrollCue direction="down" navRef={navRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Keyboard,
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RailNavList,
|
||||
useNavOverflows,
|
||||
} from "@/components/layout/sidebar-nav-scroll";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
@@ -367,69 +371,6 @@ function railRowClass(collapsed: boolean): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Height of the fade at a scroll edge that has more content beyond it. */
|
||||
const RAIL_FADE = "1.5rem";
|
||||
|
||||
/**
|
||||
* The fade mask for the list's scroll edges: a top fade while there is
|
||||
* content above, a bottom fade while there is content below. A mask paints
|
||||
* alpha only, so it reads the same in light and dark and never moves layout.
|
||||
*/
|
||||
function railFadeMask(
|
||||
top: boolean,
|
||||
bottom: boolean,
|
||||
): React.CSSProperties | undefined {
|
||||
if (!top && !bottom) return undefined;
|
||||
const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`;
|
||||
return { maskImage: image, WebkitMaskImage: image };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the nav list is taller than the room the rail leaves it, i.e.
|
||||
* whether it scrolls, and which of its edges have more content beyond them.
|
||||
* Re-measured when the nav (viewport height) or the list (entries arriving
|
||||
* as modules resolve) changes size, and on scroll.
|
||||
*/
|
||||
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
|
||||
overflows: boolean;
|
||||
moreAbove: boolean;
|
||||
moreBelow: boolean;
|
||||
} {
|
||||
const [edges, setEdges] = useState({
|
||||
overflows: false,
|
||||
moreAbove: false,
|
||||
moreBelow: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav || typeof ResizeObserver === "undefined") return;
|
||||
// Strict: any overflow, the same test that makes the list scroll.
|
||||
const update = () => {
|
||||
const overflows = nav.scrollHeight > nav.clientHeight;
|
||||
const moreAbove = overflows && nav.scrollTop > 0.5;
|
||||
const moreBelow =
|
||||
overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5;
|
||||
setEdges((prev) =>
|
||||
prev.overflows === overflows &&
|
||||
prev.moreAbove === moreAbove &&
|
||||
prev.moreBelow === moreBelow
|
||||
? prev
|
||||
: { overflows, moreAbove, moreBelow },
|
||||
);
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(nav);
|
||||
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
|
||||
nav.addEventListener("scroll", update, { passive: true });
|
||||
update();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
nav.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [navRef]);
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's collapse / expand control, drawn as a footer nav row.
|
||||
*
|
||||
@@ -547,11 +488,8 @@ export function SidebarNav() {
|
||||
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
|
||||
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
const {
|
||||
overflows: navOverflows,
|
||||
moreAbove: navMoreAbove,
|
||||
moreBelow: navMoreBelow,
|
||||
} = useNavOverflows(navRef);
|
||||
const navEdges = useNavOverflows(navRef);
|
||||
const navOverflows = navEdges.overflows;
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !collapsed;
|
||||
@@ -689,7 +627,7 @@ export function SidebarNav() {
|
||||
<aside
|
||||
aria-label={t("nav.sidebar")}
|
||||
className={cn(
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none",
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-[env(safe-area-inset-bottom,0px)] transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
@@ -737,24 +675,11 @@ export function SidebarNav() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={t("nav.mainNavigation")}
|
||||
// The list scrolls without a native scrollbar (a classic
|
||||
// scrollbar with arrow buttons read as a foreign body in the
|
||||
// rail); fades at the edges say there is more, and focus keeps
|
||||
// the focused row clear of them.
|
||||
className={cn(
|
||||
"flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden",
|
||||
collapsed ? "p-1.5" : "p-3",
|
||||
RAIL_SHORT_PY,
|
||||
)}
|
||||
style={railFadeMask(navMoreAbove, navMoreBelow)}
|
||||
data-fade-top={navMoreAbove ? "true" : undefined}
|
||||
data-fade-bottom={navMoreBelow ? "true" : undefined}
|
||||
onFocus={(event) => {
|
||||
event.target.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
<RailNavList
|
||||
navRef={navRef}
|
||||
edges={navEdges}
|
||||
label={t("nav.mainNavigation")}
|
||||
className={cn(collapsed ? "p-1.5" : "p-3", RAIL_SHORT_PY)}
|
||||
>
|
||||
<div className={RAIL_LIST_GAP}>
|
||||
{visibleNavItems.map((item) => {
|
||||
@@ -811,7 +736,7 @@ export function SidebarNav() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</RailNavList>
|
||||
|
||||
{/* Bottom utility links — the shared utility tail (minus
|
||||
Notifications, which lives in the avatar menu) with the
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The Apple Health write-back controls on the API settings (#1272).
|
||||
*
|
||||
* SSR-only, as the rest of this directory: the render pins which token rows
|
||||
* carry the switch and in which state, when the notice about readings that
|
||||
* belong to no token shows, and the write the switch sends is pinned against
|
||||
* a stubbed `fetch`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
import {
|
||||
AppleHealthMirrorOptIn,
|
||||
AppleHealthMirrorSwitch,
|
||||
UnattributedReadingsNotice,
|
||||
patchTokenMirror,
|
||||
} from "../apple-health-mirror-token";
|
||||
|
||||
function render(node: React.ReactNode, client = new QueryClient()) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const token = (permissions: string[], mirrorToAppleHealth = false) => ({
|
||||
id: "tok-1",
|
||||
name: "Scale",
|
||||
permissions,
|
||||
mirrorToAppleHealth,
|
||||
});
|
||||
|
||||
describe("the switch on a token row", () => {
|
||||
it("appears on a measurement token only", () => {
|
||||
expect(
|
||||
render(<AppleHealthMirrorSwitch token={token(["measurements:write"])} />),
|
||||
).toContain('data-slot="apple-health-mirror-switch"');
|
||||
for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) {
|
||||
expect(
|
||||
render(<AppleHealthMirrorSwitch token={token(permissions)} />),
|
||||
).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the stored setting and names the token", () => {
|
||||
const on = render(
|
||||
<AppleHealthMirrorSwitch token={token(["measurements:write"], true)} />,
|
||||
);
|
||||
expect(on).toContain('aria-checked="true"');
|
||||
expect(on).toContain("Write readings from Scale to Apple Health");
|
||||
const off = render(
|
||||
<AppleHealthMirrorSwitch token={token(["measurements:write"])} />,
|
||||
);
|
||||
expect(off).toContain('aria-checked="false"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the opt-in on the mint form", () => {
|
||||
it("reads as the setting with its explanation", () => {
|
||||
const html = render(
|
||||
<AppleHealthMirrorOptIn checked={false} onCheckedChange={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Write readings to Apple Health");
|
||||
expect(html).toContain("adds this token's readings to Apple Health");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the notice about readings no token reaches", () => {
|
||||
function withSummary(unattributedReadings: number) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.measurementTokenSummary(), {
|
||||
unattributedReadings,
|
||||
});
|
||||
return render(<UnattributedReadingsNotice />, client);
|
||||
}
|
||||
|
||||
it("shows only when there are such readings", () => {
|
||||
expect(withSummary(3)).toContain(
|
||||
"Readings sent before this setting existed can't be matched to a token",
|
||||
);
|
||||
expect(withSummary(0)).toBe("");
|
||||
expect(render(<UnattributedReadingsNotice />)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the write", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: { id: "tok 1", mirrorToAppleHealth: true },
|
||||
error: null,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("patches the token with the one field", async () => {
|
||||
await expect(patchTokenMirror("tok 1", true)).resolves.toEqual({
|
||||
id: "tok 1",
|
||||
mirrorToAppleHealth: true,
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/tokens/tok%201");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
mirrorToAppleHealth: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { Key } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** The ingest endpoints a token reaches, with the header and a body sketch. */
|
||||
export function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Dual-render: <md gets a stacked card list, md+
|
||||
// keeps the original table. Mobile cards wrap inside their box and
|
||||
// never trigger horizontal scroll, mirroring the pattern from
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
|
||||
// so adding a row remains a one-line edit on both surfaces — which is
|
||||
// what the two measurement ingest rows below cost.
|
||||
const endpoints = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/ingest/medication",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/workouts/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/documents/inbound",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="api-endpoints"
|
||||
icon={Key}
|
||||
title={t("settings.apiEndpointsTitle")}
|
||||
description={t("settings.apiEndpointsDescription")}
|
||||
/>
|
||||
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointMethod")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointPath")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{endpoints.map((endpoint) => (
|
||||
<tr key={`${endpoint.method} ${endpoint.path}`}>
|
||||
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each endpoint stacks its columns vertically
|
||||
with a labelled meta row. No horizontal scroll. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-endpoints-mobile-list"
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
<li
|
||||
key={`${endpoint.method} ${endpoint.path}`}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="font-mono break-all">{endpoint.path}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</p>
|
||||
<code className="font-mono break-all">{endpoint.auth}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</p>
|
||||
<code className="block font-mono break-all">
|
||||
{endpoint.example}
|
||||
</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,12 @@ import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ApiEndpointsCard } from "@/components/settings/api-endpoints-card";
|
||||
import {
|
||||
AppleHealthMirrorOptIn,
|
||||
AppleHealthMirrorSwitch,
|
||||
UnattributedReadingsNotice,
|
||||
} from "@/components/settings/apple-health-mirror-token";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
@@ -45,6 +51,7 @@ interface ApiTokenInfo {
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
revoked: boolean;
|
||||
mirrorToAppleHealth: boolean;
|
||||
}
|
||||
|
||||
export function ApiSection() {
|
||||
@@ -78,6 +85,8 @@ function MeasurementTokensCard() {
|
||||
icon={Upload}
|
||||
slot="settings-measurements-token-created"
|
||||
anchor="measurements-token"
|
||||
mirrorOptIn
|
||||
footer={<UnattributedReadingsNotice />}
|
||||
copy={{
|
||||
title: t("settings.measurementsToken.title"),
|
||||
description: t("settings.measurementsToken.description"),
|
||||
@@ -147,7 +156,12 @@ function IngestTokenCard({
|
||||
slot,
|
||||
anchor,
|
||||
copy,
|
||||
mirrorOptIn = false,
|
||||
footer,
|
||||
}: {
|
||||
/** Offer the Apple Health write-back on the mint (measurement tokens). */
|
||||
mirrorOptIn?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
/** Deep-link anchor for the card (`/settings/api#<anchor>`). */
|
||||
anchor: string;
|
||||
endpoint:
|
||||
@@ -172,6 +186,7 @@ function IngestTokenCard({
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
const [mirror, setMirror] = useState(false);
|
||||
// The token outlives the session, so minting one asks for a fresh proof
|
||||
// unless the session signed in or re-proved within five minutes.
|
||||
const recentProof = useRecentProof();
|
||||
@@ -184,7 +199,10 @@ function IngestTokenCard({
|
||||
try {
|
||||
// No scope field: the endpoint mints one shape, so there is nothing
|
||||
// here for a caller to choose or for this form to get wrong.
|
||||
const body = JSON.stringify({ name: newName.trim() });
|
||||
const body = JSON.stringify({
|
||||
name: newName.trim(),
|
||||
...(mirrorOptIn && { mirrorToAppleHealth: mirror }),
|
||||
});
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw(endpoint, {
|
||||
@@ -198,6 +216,7 @@ function IngestTokenCard({
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
setMirror(false);
|
||||
// The sibling card lists this token, so it has to hear about it.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
|
||||
} else {
|
||||
@@ -263,6 +282,12 @@ function IngestTokenCard({
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
{mirrorOptIn && (
|
||||
<AppleHealthMirrorOptIn
|
||||
checked={mirror}
|
||||
onCheckedChange={setMirror}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
@@ -295,137 +320,16 @@ function IngestTokenCard({
|
||||
)}
|
||||
|
||||
{tokenMsg && <p className="text-destructive text-sm">{tokenMsg}</p>}
|
||||
{footer}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.27 — dual-render: <md gets a stacked card list, md+
|
||||
// keeps the original table. Mobile cards wrap inside their box and
|
||||
// never trigger horizontal scroll, mirroring the pattern from
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
|
||||
// so adding a row remains a one-line edit on both surfaces — which is
|
||||
// what the two measurement ingest rows below cost.
|
||||
const endpoints = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/ingest/medication",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/workouts/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/documents/inbound",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="api-endpoints"
|
||||
icon={Key}
|
||||
title={t("settings.apiEndpointsTitle")}
|
||||
description={t("settings.apiEndpointsDescription")}
|
||||
/>
|
||||
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointMethod")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointPath")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{endpoints.map((endpoint) => (
|
||||
<tr key={`${endpoint.method} ${endpoint.path}`}>
|
||||
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each endpoint stacks its columns vertically
|
||||
with a labelled meta row. No horizontal scroll. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-endpoints-mobile-list"
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
<li
|
||||
key={`${endpoint.method} ${endpoint.path}`}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="font-mono break-all">{endpoint.path}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</p>
|
||||
<code className="font-mono break-all">{endpoint.auth}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</p>
|
||||
<code className="block font-mono break-all">
|
||||
{endpoint.example}
|
||||
</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
|
||||
|
||||
const {
|
||||
data: tokens,
|
||||
@@ -557,7 +461,10 @@ function ApiTokensCard() {
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{tok.name}
|
||||
<AppleHealthMirrorSwitch token={tok} />
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
@@ -660,6 +567,7 @@ function ApiTokensCard() {
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AppleHealthMirrorSwitch token={tok} />
|
||||
<ConfirmButton
|
||||
label={t("settings.tokenRevokeAction")}
|
||||
title={t("settings.tokenRevoke")}
|
||||
@@ -679,107 +587,7 @@ function ApiTokensCard() {
|
||||
</div>
|
||||
|
||||
{revokedTokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevokedTokens((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("settings.revokedTokensTitle", {
|
||||
count: revokedTokens.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<>
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{tok.name}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — stacked meta layout, no
|
||||
horizontal scroll. Revoked tokens are read-only so
|
||||
no action footer is needed. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-revoked-mobile-list"
|
||||
>
|
||||
{revokedTokens.map((tok) => (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium break-words">
|
||||
{tok.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<RevokedTokens tokens={revokedTokens} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -787,3 +595,112 @@ function ApiTokensCard() {
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The revoked tokens, folded away until asked for. Read-only but for the switch. */
|
||||
function RevokedTokens({ tokens: revokedTokens }: { tokens: ApiTokenInfo[] }) {
|
||||
const { t } = useTranslations();
|
||||
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevokedTokens((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("settings.revokedTokensTitle", {
|
||||
count: revokedTokens.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<>
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{tok.name}
|
||||
<AppleHealthMirrorSwitch token={tok} />
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — stacked meta layout, no
|
||||
horizontal scroll. Revoked tokens are read-only so
|
||||
no action footer is needed. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-revoked-mobile-list"
|
||||
>
|
||||
{revokedTokens.map((tok) => (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium break-words">{tok.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AppleHealthMirrorSwitch token={tok} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A measurement token's one setting (#1272): whether the HealthLog app on the
|
||||
* iPhone writes the readings that token sent into Apple Health.
|
||||
*
|
||||
* The readings keep their EXTERNAL source either way; the server resolves
|
||||
* the decision per reading and the app follows it. Three pieces, used by the
|
||||
* API settings: the switch on a measurement token's row, the opt-in on the
|
||||
* mint form, and the notice about readings that belong to no token.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The scope a measurement token carries, and nothing else. */
|
||||
const MEASUREMENTS_WRITE = "measurements:write";
|
||||
|
||||
export function isMeasurementTokenInfo(permissions: readonly string[]) {
|
||||
return permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE;
|
||||
}
|
||||
|
||||
/** `PATCH /api/tokens/{id}`: the one write behind the switch. */
|
||||
export function patchTokenMirror(
|
||||
tokenId: string,
|
||||
mirrorToAppleHealth: boolean,
|
||||
) {
|
||||
return apiPatch<{ id: string; mirrorToAppleHealth: boolean }>(
|
||||
`/api/tokens/${encodeURIComponent(tokenId)}`,
|
||||
{ mirrorToAppleHealth },
|
||||
);
|
||||
}
|
||||
|
||||
/** The switch on a measurement token's row; nothing on any other token. */
|
||||
export function AppleHealthMirrorSwitch({
|
||||
token,
|
||||
}: {
|
||||
token: {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
mirrorToAppleHealth: boolean;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [saving, setSaving] = useState(false);
|
||||
if (!isMeasurementTokenInfo(token.permissions)) return null;
|
||||
|
||||
async function handleChange(next: boolean) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await patchTokenMirror(token.id, next);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
|
||||
} catch (failure) {
|
||||
showErrorToast(t("settings.measurementsToken.appleHealth.updateFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex min-h-11 items-center gap-2 sm:min-h-9"
|
||||
data-slot="apple-health-mirror-switch"
|
||||
>
|
||||
<Switch
|
||||
checked={token.mirrorToAppleHealth}
|
||||
onCheckedChange={(next) => void handleChange(next)}
|
||||
disabled={saving}
|
||||
aria-label={t("settings.measurementsToken.appleHealth.toggleAria", {
|
||||
name: token.name,
|
||||
})}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{t("settings.measurementsToken.appleHealth.label")}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** The opt-in on the measurement mint form. Off unless the person ticks it. */
|
||||
export function AppleHealthMirrorOptIn({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (next: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
data-slot="apple-health-mirror-opt-in"
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => onCheckedChange(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.measurementsToken.appleHealth.label")}
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("settings.measurementsToken.appleHealth.createHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown only when the account holds EXTERNAL readings no token reaches: sent
|
||||
* before readings were linked to their token, by an account that held more
|
||||
* than one, so no setting here can put them into Apple Health.
|
||||
*/
|
||||
export function UnattributedReadingsNotice() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.measurementTokenSummary(),
|
||||
queryFn: () =>
|
||||
apiGet<{ unattributedReadings: number }>("/api/tokens/measurements"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
if (!data || !(data.unattributedReadings > 0)) return null;
|
||||
return (
|
||||
<p className="text-sm" data-slot="apple-health-unattributed-notice">
|
||||
{t("settings.measurementsToken.appleHealth.unattributed")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -30,4 +30,21 @@ describe("<TabsList>", () => {
|
||||
// Horizontal scroll behaviour is still intact for long strips.
|
||||
expect(list![0]).toMatch(/\boverflow-x-auto\b/);
|
||||
});
|
||||
|
||||
it("leaves the vertical pan to the page", () => {
|
||||
// `touch-pan-x` (or `touch-none`) on the strip refuses a vertical pan
|
||||
// that starts on it, so a swipe up or down over a tab strip scrolled
|
||||
// nothing. The strip pans sideways through its own overflow.
|
||||
const html = renderToStaticMarkup(
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList>
|
||||
<TabsTrigger value="a">A</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>,
|
||||
);
|
||||
const list = html.match(/<div\b[^>]*data-slot="tabs-list"[^>]*>/)![0];
|
||||
expect(list).not.toMatch(
|
||||
/\btouch-(?:pan-x|pan-left|pan-right|none|pinch-zoom)\b/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,8 @@ function Tabs({
|
||||
// mobile when more than ~3 tabs render at typical label widths
|
||||
// (`/insights`, `/admin`, `/settings/*`). Tab labels are i18n-driven
|
||||
// so we cannot truncate them deterministically. Instead, the list
|
||||
// becomes horizontally scrollable on mobile via `overflow-x-auto`
|
||||
// plus `touch-pan-x` for the iOS PWA, and the wrapping tabs preserve
|
||||
// becomes horizontally scrollable on mobile via `overflow-x-auto`, and the
|
||||
// wrapping tabs preserve
|
||||
// `whitespace-nowrap` so labels never word-wrap mid-tab. Desktop
|
||||
// looks identical because the inline-flex shrink-wraps inside the
|
||||
// wider container.
|
||||
@@ -47,10 +47,15 @@ function Tabs({
|
||||
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
|
||||
// (`focus-visible:ring-[3px]`) on triggers stays inside the
|
||||
// `overflow-y-hidden` clamp without clipping the bottom of the ring.
|
||||
// No `touch-action` on the strip: `overflow-x-auto` already pans it sideways
|
||||
// under a finger, and the `touch-pan-x` it once carried also refused the
|
||||
// vertical pan, so a swipe up or down that began on a tab strip scrolled
|
||||
// nothing at all (on a tablet the strip is 52 px of the page under the 44 px
|
||||
// touch floor).
|
||||
// Under a coarse pointer the strip sizes to its triggers, which take the
|
||||
// 44 px touch floor; a mouse keeps the compact 36 px strip.
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
|
||||
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface IssueTokenOptions {
|
||||
* issued and the connector token list can exclude the transient access rows.
|
||||
*/
|
||||
mcpConnectionId?: string;
|
||||
/** A measurement token's Apple Health write-back (#1272). Off by default. */
|
||||
mirrorToAppleHealth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,9 @@ export async function issueApiToken(
|
||||
...(opts.mcpConnectionId
|
||||
? { mcpConnectionId: opts.mcpConnectionId }
|
||||
: {}),
|
||||
...(opts.mirrorToAppleHealth === true
|
||||
? { mirrorToAppleHealth: true }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface MeasurementInsertRow {
|
||||
sleepStage: string | null;
|
||||
rhythmClassification: string | null;
|
||||
deviceType: string | null;
|
||||
/** The token an EXTERNAL row came from (#1272); null when absent. */
|
||||
ingestTokenId?: string | null;
|
||||
syncVersion: number;
|
||||
deletedAt: Date | null;
|
||||
createdAt?: Date;
|
||||
@@ -98,6 +100,7 @@ const COLUMNS: { [K in keyof MeasurementInsertRow]-?: [string, Kind] } = {
|
||||
sleepStage: ["sleep_stage", "enum:sleep_stage"],
|
||||
rhythmClassification: ["rhythm_classification", "enum:rhythm_classification"],
|
||||
deviceType: ["device_type", "text"],
|
||||
ingestTokenId: ["ingest_token_id", "text"],
|
||||
syncVersion: ["sync_version", "int4"],
|
||||
deletedAt: ["deleted_at", "timestamp"],
|
||||
createdAt: ["created_at", "timestamp"],
|
||||
|
||||
@@ -41,6 +41,9 @@ export const measurementSchema = z
|
||||
sleepStage: z.enum(SleepStage).nullable().optional(),
|
||||
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
|
||||
deviceType: z.string().nullable().optional(),
|
||||
// The token that carried an EXTERNAL reading (#1272). Kept on restore
|
||||
// only when the restoring account still holds that token.
|
||||
ingestTokenId: z.string().max(64).nullable().optional(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
|
||||
@@ -42,6 +42,7 @@ const MEASUREMENT_BACKUP_SELECT = {
|
||||
sleepStage: true,
|
||||
rhythmClassification: true,
|
||||
deviceType: true,
|
||||
ingestTokenId: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
@@ -86,6 +87,7 @@ function serialiseMeasurement(
|
||||
sleepStage: measurement.sleepStage,
|
||||
rhythmClassification: measurement.rhythmClassification,
|
||||
deviceType: measurement.deviceType,
|
||||
ingestTokenId: measurement.ingestTokenId,
|
||||
syncVersion: measurement.syncVersion,
|
||||
deletedAt: measurement.deletedAt?.toISOString() ?? null,
|
||||
createdAt: measurement.createdAt.toISOString(),
|
||||
|
||||
@@ -156,7 +156,10 @@ export async function clearMeasurementRollups(
|
||||
await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } });
|
||||
}
|
||||
|
||||
function toRestoredMeasurementData(measurement: BackupMeasurement) {
|
||||
function toRestoredMeasurementData(
|
||||
measurement: BackupMeasurement,
|
||||
ownTokenIds: ReadonlySet<string>,
|
||||
) {
|
||||
return {
|
||||
type: isLegacyRmssdRow(measurement)
|
||||
? ("HRV_RMSSD" as const)
|
||||
@@ -204,6 +207,13 @@ function toRestoredMeasurementData(measurement: BackupMeasurement) {
|
||||
sleepStage: (measurement.sleepStage ?? null) as never,
|
||||
rhythmClassification: (measurement.rhythmClassification ?? null) as never,
|
||||
deviceType: measurement.deviceType ?? null,
|
||||
// The token link (#1272) survives only onto the account that still holds
|
||||
// the token: a backup restored elsewhere, or after the token was deleted,
|
||||
// would point at a row that is not there or not this account's.
|
||||
ingestTokenId:
|
||||
measurement.ingestTokenId && ownTokenIds.has(measurement.ingestTokenId)
|
||||
? measurement.ingestTokenId
|
||||
: null,
|
||||
syncVersion: measurement.syncVersion ?? 1,
|
||||
deletedAt: measurement.deletedAt ? new Date(measurement.deletedAt) : null,
|
||||
...(measurement.createdAt
|
||||
@@ -343,8 +353,9 @@ async function reconcileWithoutId(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
measurement: BackupMeasurement,
|
||||
ownTokenIds: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
const restoredData = toRestoredMeasurementData(measurement);
|
||||
const restoredData = toRestoredMeasurementData(measurement, ownTokenIds);
|
||||
const existing = await tx.measurement.findFirst({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
@@ -378,6 +389,16 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
|
||||
const measurementBatchSize = 1_000;
|
||||
const isExpiredTombstone = await expiredTombstoneFilter(ctx);
|
||||
const { isShadowedLegacyRmssd } = ctx.measurementPrepass;
|
||||
// Tokens are not part of a backup; the ones the account holds now decide
|
||||
// which token links come back.
|
||||
const ownTokenIds = new Set(
|
||||
(
|
||||
await tx.apiToken.findMany({
|
||||
where: { userId: ownerId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((token) => token.id),
|
||||
);
|
||||
ctx.reportMeasurements(0);
|
||||
await streamed.forEachMeasurementBatch(
|
||||
measurementBatchSize,
|
||||
@@ -395,7 +416,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
|
||||
{
|
||||
id: measurement.id,
|
||||
userId: ownerId,
|
||||
...toRestoredMeasurementData(measurement),
|
||||
...toRestoredMeasurementData(measurement, ownTokenIds),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -412,7 +433,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
|
||||
}
|
||||
for (const measurement of batch) {
|
||||
if (measurement.id) continue;
|
||||
await reconcileWithoutId(tx, ownerId, measurement);
|
||||
await reconcileWithoutId(tx, ownerId, measurement, ownTokenIds);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The Apple Health write-back rule (#1272), over every source the enum has.
|
||||
*
|
||||
* WITHINGS, IMPORT and MANUAL are true whatever the row's token says, the
|
||||
* app's own rule as it stood; EXTERNAL follows its token; everything else is
|
||||
* false. The Prisma filter the list's `appleHealthMirror=true` uses is the
|
||||
* same rule written as a `where`, checked here against the resolver for each
|
||||
* source and token state (the database agrees in
|
||||
* `tests/integration/apple-health-mirror.test.ts`).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_WHERE,
|
||||
resolveAppleHealthMirror,
|
||||
withAppleHealthMirror,
|
||||
} from "../apple-health-mirror";
|
||||
|
||||
const TOKEN_STATES = [
|
||||
undefined,
|
||||
null,
|
||||
{ mirrorToAppleHealth: false },
|
||||
{ mirrorToAppleHealth: true },
|
||||
] as const;
|
||||
|
||||
/** The filter's two arms, evaluated against one row. */
|
||||
function matchesWhere(row: {
|
||||
source: string;
|
||||
ingestToken?: { mirrorToAppleHealth: boolean } | null;
|
||||
}): boolean {
|
||||
const [always, external] = APPLE_HEALTH_MIRROR_WHERE.OR;
|
||||
return (
|
||||
(always.source.in as readonly string[]).includes(row.source) ||
|
||||
(row.source === external.source &&
|
||||
row.ingestToken?.mirrorToAppleHealth ===
|
||||
external.ingestToken.mirrorToAppleHealth)
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveAppleHealthMirror", () => {
|
||||
it("is true for WITHINGS, IMPORT and MANUAL and an opted-in EXTERNAL", () => {
|
||||
const truthy = MEASUREMENT_SOURCES.flatMap((source) =>
|
||||
TOKEN_STATES.filter((ingestToken) =>
|
||||
resolveAppleHealthMirror({ source, ingestToken }),
|
||||
).map((ingestToken) => `${source}:${JSON.stringify(ingestToken)}`),
|
||||
);
|
||||
expect(truthy.sort()).toEqual(
|
||||
[
|
||||
...["IMPORT", "MANUAL", "WITHINGS"].flatMap((source) =>
|
||||
TOKEN_STATES.map((s) => `${source}:${JSON.stringify(s)}`),
|
||||
),
|
||||
'EXTERNAL:{"mirrorToAppleHealth":true}',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("agrees with the list filter for every source and token state", () => {
|
||||
for (const source of MEASUREMENT_SOURCES) {
|
||||
for (const ingestToken of TOKEN_STATES) {
|
||||
expect(
|
||||
matchesWhere({ source, ingestToken }),
|
||||
`${source} ${JSON.stringify(ingestToken)}`,
|
||||
).toBe(resolveAppleHealthMirror({ source, ingestToken }));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("withAppleHealthMirror", () => {
|
||||
it("adds the decision and drops the token and its id", () => {
|
||||
const row = withAppleHealthMirror({
|
||||
id: "m1",
|
||||
source: "EXTERNAL",
|
||||
ingestTokenId: "tok-1",
|
||||
ingestToken: { mirrorToAppleHealth: true },
|
||||
});
|
||||
expect(row).toEqual({
|
||||
id: "m1",
|
||||
source: "EXTERNAL",
|
||||
appleHealthMirror: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Whether the HealthLog app writes a server measurement into Apple Health
|
||||
* (#1272). Resolved here, once, and published on every measurement row as
|
||||
* `appleHealthMirror`; the app mirrors exactly the rows that say true instead
|
||||
* of deriving the decision from `source`.
|
||||
*
|
||||
* The rule:
|
||||
*
|
||||
* - WITHINGS, IMPORT and MANUAL: true. This is the app's own rule as it
|
||||
* stands (`MeasurementSource.isServerMirrorEligible`), so publishing the
|
||||
* field changes nothing for an existing row. MANUAL is deliberate: a
|
||||
* reading typed on the web or on another device is stored as MANUAL and
|
||||
* never reached Apple Health otherwise, and a manual row the app already
|
||||
* wrote at create time is recognised by the app's own dedup (the sample
|
||||
* carries the server id), not by its source.
|
||||
* - EXTERNAL: what the person chose for the `measurements:write` token that
|
||||
* carried the reading (`ApiToken.mirrorToAppleHealth`). The source stays
|
||||
* EXTERNAL either way; a token never claims another provenance. A row with
|
||||
* no token (written before the link existed and not attributable to one)
|
||||
* is not mirrored.
|
||||
* - Everything else (APPLE_HEALTH, the provider syncs, COMPUTED, TELEGRAM,
|
||||
* MCP, …): false. Apple Health rows came from Apple Health, and the
|
||||
* provider and server-written rows are never authored into it.
|
||||
*/
|
||||
import type { MeasurementSource, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/** Sources the app mirrors whatever the token says. */
|
||||
const ALWAYS_MIRRORED: ReadonlySet<MeasurementSource> = new Set([
|
||||
"WITHINGS",
|
||||
"IMPORT",
|
||||
"MANUAL",
|
||||
]);
|
||||
|
||||
/** What a row read needs to resolve the decision. */
|
||||
export const APPLE_HEALTH_MIRROR_SELECT = {
|
||||
source: true,
|
||||
ingestToken: { select: { mirrorToAppleHealth: true } },
|
||||
} as const satisfies Prisma.MeasurementSelect;
|
||||
|
||||
/** The same, for a read or write that returns the whole row. */
|
||||
export const APPLE_HEALTH_MIRROR_INCLUDE = {
|
||||
ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken,
|
||||
} as const satisfies Prisma.MeasurementInclude;
|
||||
|
||||
/** A whole row read or written with `APPLE_HEALTH_MIRROR_INCLUDE`. */
|
||||
export type MeasurementWithMirrorInput = Prisma.MeasurementGetPayload<{
|
||||
include: typeof APPLE_HEALTH_MIRROR_INCLUDE;
|
||||
}>;
|
||||
|
||||
export function resolveAppleHealthMirror(row: {
|
||||
source: MeasurementSource | string;
|
||||
ingestToken?: { mirrorToAppleHealth: boolean } | null;
|
||||
}): boolean {
|
||||
if (ALWAYS_MIRRORED.has(row.source as MeasurementSource)) return true;
|
||||
if (row.source === "EXTERNAL") {
|
||||
return row.ingestToken?.mirrorToAppleHealth === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that resolve true, as a Prisma filter: the list's
|
||||
* `appleHealthMirror=true` filter, which the app's historical backfill pages
|
||||
* instead of one fetch per source. Kept beside the rule so the two cannot
|
||||
* disagree (pinned by `apple-health-mirror.test.ts`).
|
||||
*/
|
||||
export const APPLE_HEALTH_MIRROR_WHERE = {
|
||||
OR: [
|
||||
{ source: { in: [...ALWAYS_MIRRORED] } },
|
||||
{ source: "EXTERNAL", ingestToken: { mirrorToAppleHealth: true } },
|
||||
],
|
||||
} as const satisfies Prisma.MeasurementWhereInput;
|
||||
|
||||
/**
|
||||
* A row read with `APPLE_HEALTH_MIRROR_SELECT` (or `_INCLUDE`), answered: the
|
||||
* resolved `appleHealthMirror` added, the token relation and the token id
|
||||
* dropped, so neither ever reaches the wire.
|
||||
*/
|
||||
export function withAppleHealthMirror<
|
||||
T extends {
|
||||
source: MeasurementSource | string;
|
||||
ingestToken?: { mirrorToAppleHealth: boolean } | null;
|
||||
ingestTokenId?: string | null;
|
||||
},
|
||||
>(
|
||||
row: T,
|
||||
): Omit<T, "ingestToken" | "ingestTokenId"> & { appleHealthMirror: boolean } {
|
||||
const { ingestToken, ingestTokenId: _ingestTokenId, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
appleHealthMirror: resolveAppleHealthMirror({
|
||||
source: row.source,
|
||||
ingestToken,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,11 @@ export interface BatchIngestInput {
|
||||
user: { id: string; timezone: string | null };
|
||||
/** A narrow single-purpose Bearer (a bridge), not the native client. */
|
||||
scoped: boolean;
|
||||
/**
|
||||
* The bridge token that carried the batch, set exactly when `scoped`: each
|
||||
* row it writes points at it (`Measurement.ingestTokenId`, #1272).
|
||||
*/
|
||||
ingestTokenId: string | null;
|
||||
entries: BatchIngestEntry[];
|
||||
syncTrigger: HealthKitRequestSyncTrigger | undefined;
|
||||
ipAddress: string | null;
|
||||
|
||||
@@ -44,9 +44,13 @@ export interface PreparedBatch {
|
||||
}
|
||||
|
||||
export async function prepareBatch(
|
||||
input: Pick<BatchIngestInput, "user" | "scoped" | "entries">,
|
||||
input: Pick<
|
||||
BatchIngestInput,
|
||||
"user" | "scoped" | "ingestTokenId" | "entries"
|
||||
>,
|
||||
): Promise<PreparedBatch> {
|
||||
const { user, scoped, entries } = input;
|
||||
const ingestTokenId = scoped ? input.ingestTokenId : null;
|
||||
const batch: PreparedBatch = {
|
||||
prepared: [],
|
||||
results: new Array(entries.length),
|
||||
@@ -94,7 +98,13 @@ export async function prepareBatch(
|
||||
continue;
|
||||
}
|
||||
|
||||
const prepared = prepareMeasurementEntry(index, entry, user.id, scoped);
|
||||
const prepared = prepareMeasurementEntry(
|
||||
index,
|
||||
entry,
|
||||
user.id,
|
||||
scoped,
|
||||
ingestTokenId,
|
||||
);
|
||||
if ("row" in prepared) batch.prepared.push(prepared);
|
||||
else batch.results[index] = prepared;
|
||||
}
|
||||
@@ -183,6 +193,7 @@ function prepareMeasurementEntry(
|
||||
entry: BatchIngestEntry,
|
||||
userId: string,
|
||||
scoped: boolean,
|
||||
ingestTokenId: string | null,
|
||||
): PreparedEntry | EntryResult {
|
||||
const mapped = mapAppleHealthEntry({
|
||||
hkIdentifier: entry.hkIdentifier,
|
||||
@@ -293,6 +304,8 @@ function prepareMeasurementEntry(
|
||||
// `(userId, type, EXTERNAL, externalId)`, which is the isolated
|
||||
// namespace this source exists to give.
|
||||
source: scoped ? EXTERNAL_SOURCE : (entry.source ?? "APPLE_HEALTH"),
|
||||
// The token an EXTERNAL row came from (#1272); null for the phone.
|
||||
ingestTokenId,
|
||||
measuredAt: mapped.takenAt,
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
|
||||
@@ -92,6 +92,11 @@ function desiredFor(userId: string, p: PreparedEntry) {
|
||||
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
|
||||
deviceType: p.row.deviceType as string | null,
|
||||
aggregationProvenance: statsRow ? ("HEALTHKIT_STATISTICS" as const) : null,
|
||||
// Only a bridge row names its token (#1272); a phone row leaves the
|
||||
// column as it is.
|
||||
...(p.row.ingestTokenId
|
||||
? { ingestTokenId: p.row.ingestTokenId as string }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
type MeasurementWithMirrorInput,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -39,6 +43,11 @@ export interface ReadingWriter {
|
||||
user: { id: string; timezone: string | null };
|
||||
/** A narrow bridge credential: its rows are `EXTERNAL`. */
|
||||
scoped: boolean;
|
||||
/**
|
||||
* The bridge token, set exactly when `scoped`: each row it writes points
|
||||
* at it (`Measurement.ingestTokenId`, #1272).
|
||||
*/
|
||||
ingestTokenId: string | null;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
@@ -52,7 +61,7 @@ export const DUPLICATE_READING = { duplicate: true } as const;
|
||||
export async function createReadings(
|
||||
writer: ReadingWriter,
|
||||
input: z.output<typeof createBatchMeasurementSchema>,
|
||||
): Promise<{ rows: Measurement[] } | typeof DUPLICATE_READING> {
|
||||
): Promise<{ rows: MeasurementWithMirrorInput[] } | typeof DUPLICATE_READING> {
|
||||
const { user, scoped } = writer;
|
||||
// The caller has to be told which condition the batch hit. Untouched, a
|
||||
// duplicate escaped as an unhandled error and the batch answered 500,
|
||||
@@ -77,6 +86,7 @@ export async function createReadings(
|
||||
source: (scoped
|
||||
? EXTERNAL_SOURCE
|
||||
: (m.source ?? "MANUAL")) as MeasurementSource,
|
||||
ingestTokenId: scoped ? writer.ingestTokenId : null,
|
||||
measuredAt: m.measuredAt,
|
||||
// The note is sealed at rest; the legacy plaintext column is
|
||||
// written null for new rows.
|
||||
@@ -88,6 +98,7 @@ export async function createReadings(
|
||||
// `deviceType` instead of silently dropping it.
|
||||
deviceType: m.deviceType ?? null,
|
||||
},
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -123,7 +134,7 @@ export async function createReadings(
|
||||
export async function createReading(
|
||||
writer: ReadingWriter,
|
||||
input: z.output<typeof createMeasurementSchema>,
|
||||
): Promise<{ row: Measurement } | typeof DUPLICATE_READING> {
|
||||
): Promise<{ row: MeasurementWithMirrorInput } | typeof DUPLICATE_READING> {
|
||||
const { user, scoped } = writer;
|
||||
const {
|
||||
type,
|
||||
@@ -150,6 +161,7 @@ export async function createReading(
|
||||
source: (scoped
|
||||
? EXTERNAL_SOURCE
|
||||
: (source ?? "MANUAL")) as MeasurementSource,
|
||||
ingestTokenId: scoped ? writer.ingestTokenId : null,
|
||||
measuredAt,
|
||||
// The note is sealed at rest; the legacy plaintext column is nulled.
|
||||
notes: null,
|
||||
@@ -160,6 +172,7 @@ export async function createReading(
|
||||
// silently dropping it.
|
||||
deviceType: deviceType ?? null,
|
||||
},
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
} catch (err) {
|
||||
// `isP2002` rather than `instanceof Prisma.PrismaClientKnownRequestError`.
|
||||
|
||||
@@ -23,6 +23,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
@@ -51,6 +55,8 @@ export async function createNamedSleepStage(
|
||||
args: {
|
||||
userId: string;
|
||||
scoped: boolean;
|
||||
/** The bridge token, set exactly when `scoped` (#1272). */
|
||||
ingestTokenId: string | null;
|
||||
ipAddress: string | null;
|
||||
},
|
||||
entry: z.output<typeof sleepStageEntrySchema>,
|
||||
@@ -113,6 +119,10 @@ export async function createNamedSleepStage(
|
||||
externalSourceVersion: null,
|
||||
sleepStage: segment.stage,
|
||||
deviceType: entry.deviceType ?? null,
|
||||
// The token an EXTERNAL row came from (#1272).
|
||||
...(scoped && args.ingestTokenId
|
||||
? { ingestTokenId: args.ingestTokenId }
|
||||
: {}),
|
||||
},
|
||||
{ exactExternalMatch: "duplicate" },
|
||||
),
|
||||
@@ -190,9 +200,12 @@ async function namedSleepStageResponse(
|
||||
cross_source_merge: meta.crossSource,
|
||||
},
|
||||
});
|
||||
const row = await prisma.measurement.findUniqueOrThrow({ where: { id } });
|
||||
const row = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
return apiSuccess(
|
||||
{ ...shapeMeasurementNotes(row), status },
|
||||
{ ...withAppleHealthMirror(shapeMeasurementNotes(row)), status },
|
||||
status === "inserted" ? 201 : 200,
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_SELECT,
|
||||
APPLE_HEALTH_MIRROR_WHERE,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
@@ -59,8 +64,18 @@ const MEASUREMENT_WIRE_SELECT = {
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
// Resolved onto `appleHealthMirror` and dropped by `withAppleHealthMirror`.
|
||||
ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken,
|
||||
} as const satisfies Prisma.MeasurementSelect;
|
||||
|
||||
/** One row as it is answered: the note readable, the write-back resolved. */
|
||||
function wireRow<
|
||||
T extends Parameters<typeof shapeMeasurementNotes>[0] &
|
||||
Parameters<typeof withAppleHealthMirror>[0],
|
||||
>(row: T) {
|
||||
return withAppleHealthMirror(shapeMeasurementNotes(row));
|
||||
}
|
||||
|
||||
type ListQuery = z.output<typeof listMeasurementsSchema>;
|
||||
type ListUser = { id: string; timezone: string | null };
|
||||
|
||||
@@ -202,7 +217,7 @@ async function drillDownResponse(
|
||||
return apiSuccess({
|
||||
// The same row shape as the plain page: the note decrypted onto `notes`,
|
||||
// the ciphertext never on the wire.
|
||||
measurements: samples.map(shapeMeasurementNotes),
|
||||
measurements: samples.map(wireRow),
|
||||
meta: { total: samples.length, limit, offset: 0, dayKey },
|
||||
});
|
||||
}
|
||||
@@ -281,7 +296,7 @@ async function plainPageResponse(
|
||||
query: ListQuery,
|
||||
): Promise<Response> {
|
||||
const { type, from, to, limit, offset, sortBy, sortDir } = query;
|
||||
const { sourceEq, valueMin, valueMax } = query;
|
||||
const { sourceEq, valueMin, valueMax, appleHealthMirror } = query;
|
||||
const where = {
|
||||
userId,
|
||||
// Soft-deleted readings are invisible to every list / count / aggregate
|
||||
@@ -294,6 +309,9 @@ async function plainPageResponse(
|
||||
// opt-in (which selects the rollup read tier); `sourceEq` narrows the
|
||||
// plain list to one ingest source.
|
||||
...(sourceEq && { source: sourceEq }),
|
||||
// The rows the app mirrors into Apple Health, by the same rule the
|
||||
// field is resolved with (`apple-health-mirror.ts`).
|
||||
...(appleHealthMirror === "true" && { AND: [APPLE_HEALTH_MIRROR_WHERE] }),
|
||||
// Value-range filter. Open-ended on either side; the validator has
|
||||
// already rejected an inverted `valueMin > valueMax`.
|
||||
...(valueMin != null || valueMax != null
|
||||
@@ -330,6 +348,9 @@ async function plainPageResponse(
|
||||
orderBy: [{ [sortBy]: sortDir }, { id: sortDir }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// Named columns, so a column added to the model (a token link) reaches
|
||||
// the wire only through this select.
|
||||
select: MEASUREMENT_WIRE_SELECT,
|
||||
}),
|
||||
prisma.measurement.count({ where }),
|
||||
]);
|
||||
@@ -339,7 +360,7 @@ async function plainPageResponse(
|
||||
return apiSuccess({
|
||||
// Decrypt `notesEncrypted` onto `notes` and strip the ciphertext before
|
||||
// it leaves the server (iOS MeasurementWireDTO contract).
|
||||
measurements: measurements.map(shapeMeasurementNotes),
|
||||
measurements: measurements.map(wireRow),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Switching a measurement token's Apple Health write-back (#1272), for
|
||||
* `PATCH /api/tokens/{id}`.
|
||||
*
|
||||
* One statement on the token row. The readings the token carried are not
|
||||
* touched: their `updatedAt` and `syncVersion` stay as they are, so nothing
|
||||
* keyed on them (the rollup freshness probe, personal-record detection, the
|
||||
* status re-warm) sees a change that is not one. The rows reach a paired
|
||||
* client again through the `measurementMirror` domain of `/api/sync/changes`,
|
||||
* keyed on the `mirrorChangedAt` this stamps.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
/** A token minted by `POST /api/tokens/measurements`, and nothing else. */
|
||||
export function isMeasurementToken(permissions: readonly string[]): boolean {
|
||||
return (
|
||||
permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE_SCOPE
|
||||
);
|
||||
}
|
||||
|
||||
export type TokenMirrorOutcome =
|
||||
| { kind: "not_found" }
|
||||
| { kind: "not_measurement_token" }
|
||||
| { kind: "set"; mirrorToAppleHealth: boolean };
|
||||
|
||||
export async function setMeasurementTokenMirror(input: {
|
||||
userId: string;
|
||||
tokenId: string;
|
||||
mirrorToAppleHealth: boolean;
|
||||
ipAddress: string | null;
|
||||
}): Promise<TokenMirrorOutcome> {
|
||||
const { userId, tokenId, mirrorToAppleHealth } = input;
|
||||
const token = await prisma.apiToken.findUnique({
|
||||
where: { id: tokenId },
|
||||
select: { userId: true, permissions: true, mirrorToAppleHealth: true },
|
||||
});
|
||||
// Another account's token is answered as missing, like the revoke.
|
||||
if (!token || token.userId !== userId) return { kind: "not_found" };
|
||||
// A revoked token is accepted: its readings stay in the record, and
|
||||
// whether they are mirrored is still the person's choice.
|
||||
if (!isMeasurementToken(token.permissions)) {
|
||||
return { kind: "not_measurement_token" };
|
||||
}
|
||||
|
||||
const changed = token.mirrorToAppleHealth !== mirrorToAppleHealth;
|
||||
if (changed) {
|
||||
await prisma.apiToken.update({
|
||||
where: { id: tokenId },
|
||||
data: { mirrorToAppleHealth, mirrorChangedAt: new Date() },
|
||||
});
|
||||
await auditLog("tokens.measurements.mirror.update", {
|
||||
userId,
|
||||
ipAddress: input.ipAddress,
|
||||
details: { tokenId, mirrorToAppleHealth },
|
||||
});
|
||||
}
|
||||
annotate({ meta: { mirror_to_apple_health: mirrorToAppleHealth, changed } });
|
||||
return { kind: "set", mirrorToAppleHealth };
|
||||
}
|
||||
@@ -450,6 +450,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
tokens: [
|
||||
"tokens.documents.ceiling_reached",
|
||||
"tokens.measurements.ceiling_reached",
|
||||
"tokens.mirror.not_measurement_token",
|
||||
"tokens.workouts.ceiling_reached",
|
||||
],
|
||||
vaccination: [
|
||||
|
||||
@@ -34,6 +34,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import {
|
||||
createDocumentTokenSchema,
|
||||
createMeasurementTokenSchema,
|
||||
updateMeasurementTokenSchema,
|
||||
createWorkoutTokenSchema,
|
||||
} from "@/lib/validations/tokens";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
@@ -484,9 +485,36 @@ const apiTokenInfo = z
|
||||
.describe(
|
||||
"Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity.",
|
||||
),
|
||||
mirrorToAppleHealth: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ApiTokenInfo" });
|
||||
|
||||
const updateMeasurementTokenRequest = updateMeasurementTokenSchema.meta({
|
||||
id: "UpdateMeasurementTokenRequest",
|
||||
description:
|
||||
"The one setting a measurement token has. Strict: any other key is refused 422.",
|
||||
});
|
||||
|
||||
const updateMeasurementTokenResponse = z
|
||||
.object({ id: z.string(), mirrorToAppleHealth: z.boolean() })
|
||||
.meta({ id: "UpdateMeasurementTokenResponse" });
|
||||
|
||||
const measurementTokenSummaryResponse = z
|
||||
.object({
|
||||
unattributedReadings: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
"Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an account that held more than one. No token's Apple Health setting reaches them.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MeasurementTokenSummaryResponse" });
|
||||
|
||||
const apiTokenListResponse = z
|
||||
.array(apiTokenInfo)
|
||||
.meta({ id: "ApiTokenListResponse" });
|
||||
@@ -494,7 +522,7 @@ const apiTokenListResponse = z
|
||||
const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({
|
||||
id: "CreateMeasurementTokenRequest",
|
||||
description:
|
||||
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.",
|
||||
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`.",
|
||||
});
|
||||
|
||||
const createMeasurementTokenResponse = z
|
||||
@@ -1944,6 +1972,26 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
"/api/tokens/measurements": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Readings no measurement token reaches",
|
||||
description:
|
||||
"Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session only.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The count.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
measurementTokenSummaryResponse,
|
||||
"MeasurementTokenSummaryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a measurement-ingest token",
|
||||
@@ -2170,6 +2218,42 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Change a measurement token's Apple Health setting",
|
||||
description:
|
||||
"Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 changes per account per minute. Not gated on the instance-wide API switch: no credential is created or extended.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateMeasurementTokenRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The setting as it now stands.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
updateMeasurementTokenResponse,
|
||||
"UpdateMeasurementTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Token not found or not owned by the caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed, or the token is not a measurement token (`meta.errorCode` = `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── Appended: registration, credential rotation, the OIDC entry point and
|
||||
// the Codex device-auth flow.
|
||||
|
||||
@@ -332,6 +332,12 @@ export const measurementResource = z
|
||||
.describe("Soft-delete tombstone; null on a live row."),
|
||||
createdAt: isoDateTime().optional(),
|
||||
updatedAt: isoDateTime().optional(),
|
||||
appleHealthMirror: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows.",
|
||||
),
|
||||
// Synthesised-row extras — present only on the collapsed day-sum mode.
|
||||
dayKey: z.string().optional(),
|
||||
sampleCount: z.number().int().optional(),
|
||||
|
||||
@@ -75,9 +75,29 @@ const syncMeasurementUpsert = measurementResource
|
||||
"LWW reconciliation counter; echo to keep the mirror monotonic.",
|
||||
),
|
||||
updatedAt: isoDateTime(),
|
||||
appleHealthMirror: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows that say true.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "SyncMeasurementUpsert" });
|
||||
|
||||
const syncMeasurementMirrorUpsert = z
|
||||
.object({
|
||||
id: z.string().describe("The measurement's server id."),
|
||||
appleHealthMirror: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "SyncMeasurementMirrorUpsert",
|
||||
description:
|
||||
"A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the token that sent it. The reading itself did not change and is not re-sent in `measurements`.",
|
||||
});
|
||||
|
||||
const syncMeasurementTombstone = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -294,12 +314,25 @@ export const syncChangesResponse = z
|
||||
.describe(
|
||||
"Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.",
|
||||
),
|
||||
measurementMirror: z
|
||||
.object({
|
||||
upserts: z.array(syncMeasurementMirrorUpsert),
|
||||
tombstones: z
|
||||
.array(z.object({ id: z.string() }))
|
||||
.max(0)
|
||||
.describe(
|
||||
"Always empty: a deleted reading is a measurement tombstone.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.",
|
||||
),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "SyncChangesResponse",
|
||||
description:
|
||||
"Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
|
||||
"Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
|
||||
});
|
||||
|
||||
export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
@@ -327,7 +360,7 @@ export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Sync"],
|
||||
summary: "Multi-domain delta feed",
|
||||
description:
|
||||
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
|
||||
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
|
||||
requestParams: {
|
||||
query: syncChangesQuery,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
export const integrationKeys = {
|
||||
tokens: () => ["tokens"] as const,
|
||||
// How many EXTERNAL readings belong to no measurement token (#1272). Under
|
||||
// the `["tokens"]` prefix, so a mint or a revoke refreshes it too.
|
||||
measurementTokenSummary: () => ["tokens", "measurements", "summary"] as const,
|
||||
// v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted
|
||||
// for the remote MCP endpoint). Distinct key from `tokens()` so the MCP
|
||||
// settings card's mint / revoke mutations invalidate only this list.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loadFoldTimezone,
|
||||
} from "@/lib/measurements/folded-window";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { resolveAppleHealthMirror } from "@/lib/measurements/apple-health-mirror";
|
||||
import { levelAForWire } from "@/lib/mood/level-a";
|
||||
import { contextForWire } from "@/lib/mood/context";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
CycleDayRow,
|
||||
CycleRow,
|
||||
IntakeRow,
|
||||
MeasurementMirrorRow,
|
||||
MeasurementRow,
|
||||
MoodRow,
|
||||
} from "./read-rows";
|
||||
@@ -30,6 +32,7 @@ import type {
|
||||
DomainBlock,
|
||||
IdTombstone,
|
||||
IntakeUpsert,
|
||||
MeasurementMirrorUpsert,
|
||||
MeasurementTombstone,
|
||||
MeasurementUpsert,
|
||||
MoodUpsert,
|
||||
@@ -129,6 +132,7 @@ export async function measurementBlock(
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
source: row.source,
|
||||
notes: readNote(row.notesEncrypted, row.notes),
|
||||
appleHealthMirror: resolveAppleHealthMirror(row),
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -137,6 +141,18 @@ export async function measurementBlock(
|
||||
return { upserts, tombstones };
|
||||
}
|
||||
|
||||
export function measurementMirrorBlock(
|
||||
page: MeasurementMirrorRow[],
|
||||
): DomainBlock<MeasurementMirrorUpsert, never> {
|
||||
return {
|
||||
upserts: page.map((row) => ({
|
||||
id: row.id,
|
||||
appleHealthMirror: resolveAppleHealthMirror(row),
|
||||
})),
|
||||
tombstones: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function moodBlock(
|
||||
page: MoodRow[],
|
||||
): DomainBlock<MoodUpsert, IdTombstone> {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
cycleDayBlock,
|
||||
intakeBlock,
|
||||
measurementBlock,
|
||||
measurementMirrorBlock,
|
||||
moodBlock,
|
||||
pageOf,
|
||||
} from "./domain-pages";
|
||||
@@ -72,7 +73,11 @@ async function expiry(
|
||||
const retentionHorizon = new Date(
|
||||
serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
|
||||
);
|
||||
// Not `measurementMirror`: its watermark is when a token's setting last
|
||||
// changed, which can be any age, and the domain re-delivers values, not
|
||||
// deletions, so no pruned tombstone can hide behind it.
|
||||
const pastRetention = SYNC_DOMAINS.some((domain) => {
|
||||
if (domain === "measurementMirror") return false;
|
||||
const wm = cursor[domain];
|
||||
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
|
||||
});
|
||||
@@ -99,6 +104,7 @@ function emptyChanges(): SyncChangesBody["changes"] {
|
||||
intakes: { upserts: [], tombstones: [] },
|
||||
cycleDays: { upserts: [], tombstones: [] },
|
||||
cycles: { upserts: [], tombstones: [] },
|
||||
measurementMirror: { upserts: [], tombstones: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,8 +192,25 @@ export async function pullSyncChanges(input: {
|
||||
const cycles = cycleBlock(cy.page);
|
||||
advanceCursor(nextCursor, "cycles", cy.page);
|
||||
|
||||
const mm = pageOf(rows.measurementMirror, limit);
|
||||
const measurementMirror = measurementMirrorBlock(mm.page);
|
||||
advanceCursor(
|
||||
nextCursor,
|
||||
"measurementMirror",
|
||||
mm.page.map((row) => ({
|
||||
// The walk only returns rows whose token carries a change stamp.
|
||||
updatedAt: row.ingestToken!.mirrorChangedAt!,
|
||||
id: row.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasMore =
|
||||
m.hasMore || md.hasMore || ik.hasMore || cd.hasMore || cy.hasMore;
|
||||
m.hasMore ||
|
||||
md.hasMore ||
|
||||
ik.hasMore ||
|
||||
cd.hasMore ||
|
||||
cy.hasMore ||
|
||||
mm.hasMore;
|
||||
|
||||
annotate({
|
||||
action: { name: "sync.changes.pull" },
|
||||
@@ -202,6 +225,7 @@ export async function pullSyncChanges(input: {
|
||||
cycle_day_tombstones: cycleDays.tombstones.length,
|
||||
cycle_upserts: cycles.upserts.length,
|
||||
cycle_tombstones: cycles.tombstones.length,
|
||||
measurement_mirror_upserts: measurementMirror.upserts.length,
|
||||
cycle_module_enabled: cycleEnabled,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(token),
|
||||
@@ -213,6 +237,13 @@ export async function pullSyncChanges(input: {
|
||||
cursor: encodeCursor(nextCursor, serverNow.getTime()),
|
||||
hasMore,
|
||||
cursorExpired: false,
|
||||
changes: { measurements, mood, intakes, cycleDays, cycles },
|
||||
changes: {
|
||||
measurements,
|
||||
mood,
|
||||
intakes,
|
||||
cycleDays,
|
||||
cycles,
|
||||
measurementMirror,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { DomainWatermark, SyncCursor } from "@/lib/sync/cursor";
|
||||
import { dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
import { APPLE_HEALTH_MIRROR_SELECT } from "@/lib/measurements/apple-health-mirror";
|
||||
|
||||
const MEASUREMENT_SELECT = {
|
||||
id: true,
|
||||
@@ -24,6 +25,13 @@ const MEASUREMENT_SELECT = {
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
updatedAt: true,
|
||||
ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken,
|
||||
} satisfies Prisma.MeasurementSelect;
|
||||
|
||||
const MEASUREMENT_MIRROR_SELECT = {
|
||||
id: true,
|
||||
source: true,
|
||||
ingestToken: { select: { mirrorToAppleHealth: true, mirrorChangedAt: true } },
|
||||
} satisfies Prisma.MeasurementSelect;
|
||||
|
||||
const MOOD_SELECT = {
|
||||
@@ -73,6 +81,9 @@ export type CycleDayRow = Prisma.CycleDayLogGetPayload<{
|
||||
include: typeof dayLogSymptomInclude;
|
||||
}>;
|
||||
export type CycleRow = Prisma.MenstrualCycleGetPayload<object>;
|
||||
export type MeasurementMirrorRow = Prisma.MeasurementGetPayload<{
|
||||
select: typeof MEASUREMENT_MIRROR_SELECT;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
|
||||
@@ -95,6 +106,44 @@ export interface DomainRows {
|
||||
intakes: IntakeRow[];
|
||||
cycleDays: CycleDayRow[];
|
||||
cycles: CycleRow[];
|
||||
measurementMirror: MeasurementMirrorRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The live readings of every token whose Apple Health setting changed past
|
||||
* the watermark, in `(token.mirrorChangedAt, measurement.id)` order. Switching
|
||||
* the setting writes the token row only, so the readings keep their
|
||||
* `updatedAt` (nothing keyed on it recomputes) and arrive through this walk
|
||||
* instead of the measurement one.
|
||||
*/
|
||||
function readMeasurementMirrorRows(
|
||||
userId: string,
|
||||
wm: DomainWatermark | undefined,
|
||||
limit: number,
|
||||
): Promise<MeasurementMirrorRow[]> {
|
||||
const at = wm ? new Date(wm.updatedAtMs) : null;
|
||||
return prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
ingestToken: { mirrorChangedAt: { not: null } },
|
||||
...(at &&
|
||||
wm && {
|
||||
OR: [
|
||||
{ ingestToken: { mirrorChangedAt: { gt: at } } },
|
||||
{
|
||||
AND: [
|
||||
{ ingestToken: { mirrorChangedAt: at } },
|
||||
{ id: { gt: wm.id } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: [{ ingestToken: { mirrorChangedAt: "asc" } }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MEASUREMENT_MIRROR_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,40 +157,42 @@ export async function readDomainRows(
|
||||
limit: number,
|
||||
cycleEnabled: boolean,
|
||||
): Promise<DomainRows> {
|
||||
const [measurements, mood, intakes, cycleDays, cycles] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.measurements) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MEASUREMENT_SELECT,
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.mood) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MOOD_SELECT,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.intakes) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: INTAKE_SELECT,
|
||||
}),
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
return { measurements, mood, intakes, cycleDays, cycles };
|
||||
const [measurements, mood, intakes, cycleDays, cycles, measurementMirror] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.measurements) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MEASUREMENT_SELECT,
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.mood) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MOOD_SELECT,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.intakes) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: INTAKE_SELECT,
|
||||
}),
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
readMeasurementMirrorRows(userId, cursor.measurementMirror, limit),
|
||||
]);
|
||||
return { measurements, mood, intakes, cycleDays, cycles, measurementMirror };
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface MeasurementUpsert {
|
||||
measuredAt: string;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
/** Whether the app writes the reading into Apple Health (#1272). */
|
||||
appleHealthMirror: boolean;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -97,12 +99,23 @@ export interface DomainBlock<U, T> {
|
||||
tombstones: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading whose Apple Health write-back changed because its token's
|
||||
* setting did (#1272). The reading itself did not change and is not re-sent.
|
||||
*/
|
||||
export interface MeasurementMirrorUpsert {
|
||||
id: string;
|
||||
appleHealthMirror: boolean;
|
||||
}
|
||||
|
||||
export interface SyncChanges {
|
||||
measurements: DomainBlock<MeasurementUpsert, MeasurementTombstone>;
|
||||
mood: DomainBlock<MoodUpsert, IdTombstone>;
|
||||
intakes: DomainBlock<IntakeUpsert, IdTombstone>;
|
||||
cycleDays: DomainBlock<CycleDayLogDTO, CycleDayTombstone>;
|
||||
cycles: DomainBlock<MenstrualCycleDTO, IdTombstone>;
|
||||
/** Never carries tombstones: a deleted reading is a measurement tombstone. */
|
||||
measurementMirror: DomainBlock<MeasurementMirrorUpsert, never>;
|
||||
}
|
||||
|
||||
export interface SyncChangesBody {
|
||||
|
||||
+17
-2
@@ -28,19 +28,34 @@
|
||||
|
||||
/** The sync domains the feed serves. */
|
||||
export type SyncDomain =
|
||||
"measurements" | "mood" | "intakes" | "cycleDays" | "cycles";
|
||||
| "measurements"
|
||||
| "mood"
|
||||
| "intakes"
|
||||
| "cycleDays"
|
||||
| "cycles"
|
||||
| "measurementMirror";
|
||||
|
||||
/**
|
||||
* `measurementMirror` (#1272) is additive inside the v2 envelope: a token
|
||||
* without it decodes as before and that domain scans from the start. Its
|
||||
* watermark is not a row's `updatedAt` but the `mirrorChangedAt` of the
|
||||
* token whose readings were last re-delivered (see `read-rows.ts`).
|
||||
*/
|
||||
export const SYNC_DOMAINS: readonly SyncDomain[] = [
|
||||
"measurements",
|
||||
"mood",
|
||||
"intakes",
|
||||
"cycleDays",
|
||||
"cycles",
|
||||
"measurementMirror",
|
||||
];
|
||||
|
||||
/** A single domain's keyset position. */
|
||||
export interface DomainWatermark {
|
||||
/** `updatedAt` of the last drained row, as epoch milliseconds. */
|
||||
/**
|
||||
* `updatedAt` of the last drained row, as epoch milliseconds; for
|
||||
* `measurementMirror`, the `mirrorChangedAt` of that row's token.
|
||||
*/
|
||||
updatedAtMs: number;
|
||||
/** `id` of the last drained row (cuid) — the keyset tie-breaker. */
|
||||
id: string;
|
||||
|
||||
@@ -394,6 +394,10 @@ export const listMeasurementsSchema = z
|
||||
// threaded into the plain-list `where`. Backed by the `(userId, source,
|
||||
// measuredAt)` index (migration 0136).
|
||||
sourceEq: measurementSourceEnum.optional(),
|
||||
// Only the rows the HealthLog app writes into Apple Health
|
||||
// (`appleHealthMirror`, #1272): the app's historical backfill pages this
|
||||
// instead of one fetch per source. A plain-list filter.
|
||||
appleHealthMirror: z.enum(["true"]).optional(),
|
||||
// v1.18.5 — value-range FILTER (backlog G). Narrows the plain list to
|
||||
// readings whose `value` falls within an optional `[valueMin, valueMax]`
|
||||
// band. Either bound may be omitted for an open-ended range. Coerced
|
||||
@@ -478,6 +482,23 @@ export const listMeasurementsSchema = z
|
||||
message: "valueMin must be <= valueMax",
|
||||
path: ["valueMin"],
|
||||
},
|
||||
)
|
||||
// `appleHealthMirror` narrows the plain row list. The other modes answer
|
||||
// something else (night rows, day buckets, a drill-down, a series), where
|
||||
// a row filter would either do nothing or change what the mode means.
|
||||
.refine(
|
||||
({ appleHealthMirror, type, dayKey, groupBy, aggregate, source }) =>
|
||||
appleHealthMirror === undefined ||
|
||||
(type !== "SLEEP_DURATION" &&
|
||||
dayKey === undefined &&
|
||||
groupBy === undefined &&
|
||||
source === undefined &&
|
||||
(aggregate === undefined || aggregate === "raw")),
|
||||
{
|
||||
message:
|
||||
"appleHealthMirror filters the plain row list only; combine it with type, from, to, sourceEq and paging",
|
||||
path: ["appleHealthMirror"],
|
||||
},
|
||||
);
|
||||
|
||||
export const createBatchMeasurementSchema = z.object({
|
||||
|
||||
@@ -19,6 +19,21 @@ import { z } from "zod/v4";
|
||||
export const createMeasurementTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
/**
|
||||
* Whether the HealthLog app writes this token's readings into Apple Health
|
||||
* (#1272). Off unless the person turns it on; changeable later with
|
||||
* `PATCH /api/tokens/{id}`.
|
||||
*/
|
||||
mirrorToAppleHealth: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Body of `PATCH /api/tokens/{id}`: the one setting a measurement token has.
|
||||
* Strict, so a key this route does not understand (a `permissions`, a
|
||||
* `name`) is refused rather than ignored.
|
||||
*/
|
||||
export const updateMeasurementTokenSchema = z.strictObject({
|
||||
mirrorToAppleHealth: z.boolean(),
|
||||
});
|
||||
|
||||
export type CreateMeasurementTokenInput = z.infer<
|
||||
|
||||
Reference in new issue
Block a user