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