mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -0,0 +1,259 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { formatMeasurementsForExport, toCSV } from "@/lib/export";
|
||||
import { MEASUREMENT_EXPORT_PAGE_SIZE } from "@/lib/export/paged-measurements";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { GET } from "../measurements/route";
|
||||
import { GET as GET_LEGACY } from "../route";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", role: "USER" as const },
|
||||
};
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/export/measurements?granularity=raw",
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
function legacyRequest(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/export?format=json&type=measurements",
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
function measurement(index: number) {
|
||||
return {
|
||||
id: `m-${String(index).padStart(5, "0")}`,
|
||||
type: index % 2 === 0 ? "WEIGHT" : "HEART_RATE",
|
||||
value: index + 0.25,
|
||||
unit: index % 2 === 0 ? "kg" : "bpm",
|
||||
measuredAt: new Date(Date.UTC(2026, 6, 21, 12, 0, 0) - index * 1_000),
|
||||
source: "MANUAL",
|
||||
notes: index === 1 ? 'comma, quote " and\nnewline' : null,
|
||||
notesEncrypted: null,
|
||||
glucoseContext: null,
|
||||
sleepStage: null,
|
||||
deviceType: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(auditLog).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/export/measurements streaming", () => {
|
||||
it("does not audit a failed authorization", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const response = await GET(request());
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the audit event before returning the streaming response", async () => {
|
||||
let persistAudit!: () => void;
|
||||
const auditPersistence = new Promise<void>((resolve) => {
|
||||
persistAudit = resolve;
|
||||
});
|
||||
let markAuditStarted: (() => void) | undefined;
|
||||
const auditStarted = new Promise<void>((resolve) => {
|
||||
markAuditStarted = resolve;
|
||||
});
|
||||
vi.mocked(auditLog).mockImplementationOnce(() => {
|
||||
markAuditStarted?.();
|
||||
return auditPersistence;
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const responsePromise = GET(request());
|
||||
const firstSettled = await Promise.race([
|
||||
responsePromise.then(() => "response" as const),
|
||||
auditStarted.then(() => "audit" as const),
|
||||
]);
|
||||
|
||||
persistAudit();
|
||||
const response = await responsePromise;
|
||||
expect(response.status).toBe(200);
|
||||
expect(firstSettled).toBe("audit");
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
await response.body?.cancel();
|
||||
});
|
||||
|
||||
it("streams exact CSV across a keyset page boundary", async () => {
|
||||
const firstPage = Array.from(
|
||||
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
|
||||
(_, index) => measurement(index),
|
||||
);
|
||||
const finalPage = [measurement(MEASUREMENT_EXPORT_PAGE_SIZE)];
|
||||
vi.mocked(prisma.measurement.findMany)
|
||||
.mockResolvedValueOnce(firstPage as never)
|
||||
.mockResolvedValueOnce(finalPage as never);
|
||||
|
||||
const response = await GET(request());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).not.toBeNull();
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = await response.text();
|
||||
const allRows = [...firstPage, ...finalPage];
|
||||
const expected = toCSV(
|
||||
formatMeasurementsForExport(allRows, "UTC", {
|
||||
granularity: "raw",
|
||||
sleepTz: "UTC",
|
||||
sourcePriorityJson: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(body).toBe(expected);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
expect(auditLog).toHaveBeenCalledWith("user.export.measurements", {
|
||||
userId: "user-1",
|
||||
ipAddress: null,
|
||||
details: {
|
||||
outcome: "attempted",
|
||||
since: null,
|
||||
until: null,
|
||||
},
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates a later page failure through the response body", async () => {
|
||||
const failure = new Error("measurement page failed");
|
||||
const firstPage = Array.from(
|
||||
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
|
||||
(_, index) => measurement(index),
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany)
|
||||
.mockResolvedValueOnce(firstPage as never)
|
||||
.mockRejectedValueOnce(failure);
|
||||
|
||||
const response = await GET(request());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||||
await expect(response.text()).rejects.toThrow("measurement page failed");
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export legacy measurement streaming", () => {
|
||||
it("does not audit a failed authorization", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const response = await GET_LEGACY(legacyRequest());
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the audit event before returning the streaming response", async () => {
|
||||
let persistAudit!: () => void;
|
||||
const auditPersistence = new Promise<void>((resolve) => {
|
||||
persistAudit = resolve;
|
||||
});
|
||||
let markAuditStarted: (() => void) | undefined;
|
||||
const auditStarted = new Promise<void>((resolve) => {
|
||||
markAuditStarted = resolve;
|
||||
});
|
||||
vi.mocked(auditLog).mockImplementationOnce(() => {
|
||||
markAuditStarted?.();
|
||||
return auditPersistence;
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const responsePromise = GET_LEGACY(legacyRequest());
|
||||
const firstSettled = await Promise.race([
|
||||
responsePromise.then(() => "response" as const),
|
||||
auditStarted.then(() => "audit" as const),
|
||||
]);
|
||||
|
||||
persistAudit();
|
||||
const response = await responsePromise;
|
||||
expect(response.status).toBe(200);
|
||||
expect(firstSettled).toBe("audit");
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
await response.body?.cancel();
|
||||
});
|
||||
|
||||
it("frames exact JSON while pulling later measurement pages from the body", async () => {
|
||||
const firstPage = Array.from(
|
||||
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
|
||||
(_, index) => measurement(index),
|
||||
);
|
||||
const finalPage = [measurement(MEASUREMENT_EXPORT_PAGE_SIZE)];
|
||||
vi.mocked(prisma.measurement.findMany)
|
||||
.mockResolvedValueOnce(firstPage as never)
|
||||
.mockResolvedValueOnce(finalPage as never);
|
||||
|
||||
const response = await GET_LEGACY(legacyRequest());
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = await response.text();
|
||||
const records = formatMeasurementsForExport(
|
||||
[...firstPage, ...finalPage],
|
||||
"UTC",
|
||||
{
|
||||
sleepTz: "UTC",
|
||||
sourcePriorityJson: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
},
|
||||
);
|
||||
expect(body).toBe(JSON.stringify({ data: { measurements: records } }));
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
expect(auditLog).toHaveBeenCalledWith("export.download", {
|
||||
userId: "user-1",
|
||||
ipAddress: null,
|
||||
details: {
|
||||
format: "json",
|
||||
type: "measurements",
|
||||
outcome: "attempted",
|
||||
},
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,7 @@ describe("GET /api/export/measurements", () => {
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "measurement-weight-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
@@ -166,6 +167,7 @@ describe("GET /api/export/measurements", () => {
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "measurement-glucose-mmol",
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
@@ -196,6 +198,7 @@ describe("GET /api/export/measurements", () => {
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "measurement-glucose-mgdl",
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
|
||||
@@ -22,9 +22,11 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import { toCSV, type ExportableRecord } from "@/lib/export";
|
||||
import {
|
||||
formatMeasurementPageChunks,
|
||||
iterateMeasurementPages,
|
||||
} from "@/lib/export/paged-measurements";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
@@ -41,80 +43,123 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { since, until } = parseRange(request.url);
|
||||
const where = buildWhere(user.id, { since, until });
|
||||
// v1.11.5 — sleep export defaults to one row per night; `granularity=raw`
|
||||
// keeps the per-stage rows for power users.
|
||||
const granularity =
|
||||
new URL(request.url).searchParams.get("granularity") === "raw"
|
||||
? "raw"
|
||||
: "night";
|
||||
|
||||
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
|
||||
[
|
||||
// v1.28.25 — keyset-paginated read with a narrow select. The route
|
||||
// keeps its contract (an absent `since`/`until` means the full
|
||||
// history), but the read is now chunked so an unbounded window on a
|
||||
// CGM / per-sample-HR account never materialises the whole table in
|
||||
// one query. The select is exactly the formatter's `ExportMeasurement`
|
||||
// shape + note decryption + the `id` cursor key.
|
||||
findMeasurementsPaged(prisma, where, {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
deviceType: true,
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
// v1.16.16 — resolve the glucose display unit so BLOOD_GLUCOSE rows
|
||||
// export in the user's unit (matching FHIR), not raw stored mg/dL.
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
}),
|
||||
],
|
||||
);
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
|
||||
const csv = toCSV(
|
||||
formatMeasurementsForExport(
|
||||
measurements.map(shapeMeasurementNotes),
|
||||
userTz,
|
||||
{
|
||||
granularity,
|
||||
sleepTz: userTz,
|
||||
sourcePriorityJson,
|
||||
glucoseUnit,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await auditLog("user.export.measurements", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
count: measurements.length,
|
||||
outcome: "attempted",
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_count: measurements.length,
|
||||
export_outcome: "attempted",
|
||||
export_since: since?.toISOString() ?? null,
|
||||
export_until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
const pageIterator = iterateMeasurementPages(prisma, where, {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
deviceType: true,
|
||||
})[Symbol.asyncIterator]();
|
||||
|
||||
return csvResponse(csv, `healthlog-measurements-${user.id}`);
|
||||
// Prime one bounded page before sending headers. Authentication, metadata,
|
||||
// and first-query failures therefore keep the historical API error
|
||||
// behaviour; later pages are pulled only as the client consumes the body.
|
||||
const [firstPage, userTz, sourcePriorityJson, profile] = await Promise.all([
|
||||
pageIterator.next(),
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
}),
|
||||
]).catch(async (error: unknown) => {
|
||||
await pageIterator.return?.();
|
||||
throw error;
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
const pages = prefetchedPages(pageIterator, firstPage);
|
||||
const recordChunks = formatMeasurementPageChunks(pages, userTz, {
|
||||
granularity,
|
||||
sourcePriorityJson,
|
||||
glucoseUnit,
|
||||
});
|
||||
const body = streamText(csvChunks(recordChunks));
|
||||
|
||||
return csvResponse(body, `healthlog-measurements-${user.id}`);
|
||||
});
|
||||
|
||||
async function* prefetchedPages<T>(
|
||||
iterator: AsyncIterator<T[]>,
|
||||
first: IteratorResult<T[]>,
|
||||
): AsyncGenerator<readonly T[], void, void> {
|
||||
try {
|
||||
let next = first;
|
||||
while (!next.done) {
|
||||
yield next.value;
|
||||
next = await iterator.next();
|
||||
}
|
||||
} finally {
|
||||
await iterator.return?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function* csvChunks(
|
||||
chunks: AsyncIterable<ExportableRecord[]>,
|
||||
): AsyncGenerator<string, void, void> {
|
||||
let wroteHeader = false;
|
||||
for await (const records of chunks) {
|
||||
const csv = toCSV(records);
|
||||
if (csv.length === 0) continue;
|
||||
if (!wroteHeader) {
|
||||
wroteHeader = true;
|
||||
yield csv;
|
||||
continue;
|
||||
}
|
||||
const headerEnd = csv.indexOf("\n");
|
||||
if (headerEnd >= 0 && headerEnd + 1 < csv.length) {
|
||||
yield `\n${csv.slice(headerEnd + 1)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamText(chunks: AsyncIterable<string>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const iterator = chunks[Symbol.asyncIterator]();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await iterator.next();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(next.value));
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await iterator.return?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseRange(url: string): {
|
||||
since: Date | undefined;
|
||||
until: Date | undefined;
|
||||
@@ -159,7 +204,10 @@ function buildWhere(
|
||||
return where;
|
||||
}
|
||||
|
||||
function csvResponse(body: string, prefix: string): NextResponse {
|
||||
function csvResponse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
prefix: string,
|
||||
): NextResponse {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
|
||||
+154
-47
@@ -6,13 +6,16 @@ import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
toCSV,
|
||||
formatMeasurementsForExport,
|
||||
formatMedicationsForExport,
|
||||
formatIntakeEventsForExport,
|
||||
formatMoodEntriesForExport,
|
||||
type ExportableRecord,
|
||||
} from "@/lib/export";
|
||||
import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
|
||||
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
formatMeasurementPageChunks,
|
||||
iterateMeasurementPages,
|
||||
} from "@/lib/export/paged-measurements";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
@@ -52,26 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
await auditLog("export.download", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { format, type, outcome: "attempted" },
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
export_format: format,
|
||||
export_type: type,
|
||||
export_outcome: "attempted",
|
||||
},
|
||||
});
|
||||
const [userTz, sourcePriorityJson] = await Promise.all([
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
let measurementChunks: AsyncIterable<ExportableRecord[]> | null = null;
|
||||
|
||||
if (type === "measurements" || type === "all") {
|
||||
// v1.28.25 — keyset-paginated read with a narrow select. The legacy
|
||||
// export has no date window, so a CGM / per-sample-HR account holds a
|
||||
// six-figure row set; one unbounded full-width `findMany` here was the
|
||||
// scale hazard. The select carries exactly the fields the formatter +
|
||||
// note decryption consume (`ExportMeasurement` + `shapeMeasurementNotes`
|
||||
// + the `id` cursor key) — no Bytes column beyond the note ciphertext
|
||||
// the export must decrypt.
|
||||
const measurements = await findMeasurementsPaged(
|
||||
const pageIterator = iterateMeasurementPages(
|
||||
prisma,
|
||||
// v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
|
||||
// the legacy /api/export endpoint so CSV + JSON downloads stay
|
||||
// consistent with the live measurement reads.
|
||||
{ userId, deletedAt: null },
|
||||
{
|
||||
id: true,
|
||||
@@ -86,19 +91,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
sleepStage: true,
|
||||
deviceType: true,
|
||||
},
|
||||
);
|
||||
// v1.11.5 — sleep collapses to one row per night by default (the
|
||||
// formatter's `night` granularity), matching the per-type CSV route.
|
||||
// Thread the user's source-priority so a multi-source night dedups to
|
||||
// the same canonical source the UI shows.
|
||||
const measurementRecords = formatMeasurementsForExport(
|
||||
// v1.23 — decrypt notes before formatting; strip the ciphertext column.
|
||||
measurements.map(shapeMeasurementNotes),
|
||||
)[Symbol.asyncIterator]();
|
||||
const firstPage = await pageIterator.next();
|
||||
measurementChunks = formatMeasurementPageChunks(
|
||||
prefetchedPages(pageIterator, firstPage),
|
||||
userTz,
|
||||
{ sleepTz: userTz, sourcePriorityJson },
|
||||
{
|
||||
granularity: "night",
|
||||
sourcePriorityJson,
|
||||
// Preserve the legacy endpoint's canonical storage-unit contract.
|
||||
glucoseUnit: "mg/dL",
|
||||
},
|
||||
);
|
||||
data.measurements =
|
||||
format === "csv" ? toCSV(measurementRecords) : measurementRecords;
|
||||
}
|
||||
|
||||
if (type === "medications" || type === "all") {
|
||||
@@ -115,8 +119,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
if (type === "intake" || type === "all") {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the export (mirrors
|
||||
// the measurement export's `deletedAt: null` filter above).
|
||||
where: { userId, deletedAt: null },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
@@ -129,7 +131,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
if (type === "mood" || type === "all") {
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the export.
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
@@ -140,36 +141,50 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
: formatMoodEntriesForExport(decryptedMood, userTz);
|
||||
}
|
||||
|
||||
await auditLog("export.download", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { format, type },
|
||||
});
|
||||
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
annotate({ meta: { export_format: format, export_type: type } });
|
||||
if (measurementChunks && format === "csv") {
|
||||
return new NextResponse(
|
||||
streamText(legacyCsvChunks(measurementChunks, data)),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}.csv"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (measurementChunks) {
|
||||
return new NextResponse(
|
||||
streamText(legacyJsonChunks(measurementChunks, data)),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="${filename}.json"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "csv") {
|
||||
// For CSV with all types, combine into separate sections
|
||||
const csvParts: string[] = [];
|
||||
if (data.measurements) {
|
||||
csvParts.push("# Measurements\n" + data.measurements);
|
||||
}
|
||||
if (data.medications) {
|
||||
if (typeof data.medications === "string" && data.medications.length > 0) {
|
||||
csvParts.push("# Medications\n" + data.medications);
|
||||
}
|
||||
if (data.intakeEvents) {
|
||||
if (typeof data.intakeEvents === "string" && data.intakeEvents.length > 0) {
|
||||
csvParts.push("# Intake Events\n" + data.intakeEvents);
|
||||
}
|
||||
if (data.moodEntries) {
|
||||
if (typeof data.moodEntries === "string" && data.moodEntries.length > 0) {
|
||||
csvParts.push("# Mood Entries\n" + data.moodEntries);
|
||||
}
|
||||
const csvContent = csvParts.join("\n\n");
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
return new NextResponse(csvParts.join("\n\n"), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
"Content-Disposition": `attachment; filename="${filename}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -179,8 +194,100 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.json"`,
|
||||
"Content-Disposition": `attachment; filename="${filename}.json"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function* prefetchedPages<T>(
|
||||
iterator: AsyncIterator<T[]>,
|
||||
first: IteratorResult<T[]>,
|
||||
): AsyncGenerator<readonly T[], void, void> {
|
||||
try {
|
||||
let next = first;
|
||||
while (!next.done) {
|
||||
yield next.value;
|
||||
next = await iterator.next();
|
||||
}
|
||||
} finally {
|
||||
await iterator.return?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function* legacyCsvChunks(
|
||||
measurementChunks: AsyncIterable<ExportableRecord[]>,
|
||||
data: Record<string, unknown>,
|
||||
): AsyncGenerator<string, void, void> {
|
||||
let wroteSection = false;
|
||||
let wroteMeasurementHeader = false;
|
||||
for await (const records of measurementChunks) {
|
||||
const csv = toCSV(records);
|
||||
if (csv.length === 0) continue;
|
||||
if (!wroteMeasurementHeader) {
|
||||
wroteMeasurementHeader = true;
|
||||
wroteSection = true;
|
||||
yield `# Measurements\n${csv}`;
|
||||
continue;
|
||||
}
|
||||
const headerEnd = csv.indexOf("\n");
|
||||
if (headerEnd >= 0 && headerEnd + 1 < csv.length) {
|
||||
yield `\n${csv.slice(headerEnd + 1)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const staticSections: Array<[string, unknown]> = [
|
||||
["Medications", data.medications],
|
||||
["Intake Events", data.intakeEvents],
|
||||
["Mood Entries", data.moodEntries],
|
||||
];
|
||||
for (const [heading, value] of staticSections) {
|
||||
if (typeof value !== "string" || value.length === 0) continue;
|
||||
yield `${wroteSection ? "\n\n" : ""}# ${heading}\n${value}`;
|
||||
wroteSection = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function* legacyJsonChunks(
|
||||
measurementChunks: AsyncIterable<ExportableRecord[]>,
|
||||
data: Record<string, unknown>,
|
||||
): AsyncGenerator<string, void, void> {
|
||||
yield '{"data":{"measurements":[';
|
||||
let wroteMeasurement = false;
|
||||
for await (const records of measurementChunks) {
|
||||
if (records.length === 0) continue;
|
||||
const json = records.map((record) => JSON.stringify(record)).join(",");
|
||||
yield `${wroteMeasurement ? "," : ""}${json}`;
|
||||
wroteMeasurement = true;
|
||||
}
|
||||
yield "]";
|
||||
|
||||
const staticKeys = ["medications", "intakeEvents", "moodEntries"] as const;
|
||||
for (const key of staticKeys) {
|
||||
if (!(key in data)) continue;
|
||||
yield `,${JSON.stringify(key)}:${JSON.stringify(data[key])}`;
|
||||
}
|
||||
yield "}}";
|
||||
}
|
||||
|
||||
function streamText(chunks: AsyncIterable<string>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const iterator = chunks[Symbol.asyncIterator]();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await iterator.next();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(next.value));
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await iterator.return?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user