mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'fix/v1393-review-data' into release/v1.39.3
This commit is contained in:
commit
72a34b3b47
39 files changed
+1118
-188
No files matched your search
@@ -167,9 +167,20 @@ export const GET = apiHandler(async () => {
|
||||
a.username.localeCompare(b.username),
|
||||
);
|
||||
|
||||
// A wipe whose copies are already gone waits for the next nightly run
|
||||
// before its request is removed (see `offhost-purge.ts`); nothing of it is
|
||||
// left in the bucket, so it is not counted as a pending deletion.
|
||||
const stillInBucket = {
|
||||
NOT: {
|
||||
reason: "data_wiped",
|
||||
lastFailure: null,
|
||||
lastAttemptAt: { not: null },
|
||||
},
|
||||
};
|
||||
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
|
||||
prisma.offhostPurgeRequest.count(),
|
||||
prisma.offhostPurgeRequest.count({ where: stillInBucket }),
|
||||
prisma.offhostPurgeRequest.findFirst({
|
||||
where: stillInBucket,
|
||||
orderBy: { requestedAt: "asc" },
|
||||
select: { requestedAt: true, lastFailure: true },
|
||||
}),
|
||||
|
||||
@@ -227,7 +227,11 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
|
||||
it("surfaces an externalId row as updated when it already existed (updateMany)", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", externalId: "ext-1" },
|
||||
{
|
||||
type: "WEIGHT",
|
||||
externalId: "ext-1",
|
||||
measuredAt: new Date("2026-05-01T08:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
@@ -400,7 +404,11 @@ describe("POST /api/import/csv — contextless blood glucose", () => {
|
||||
it("keeps a re-upload of the same external id idempotent", async () => {
|
||||
// The key already exists under (userId, type, source=IMPORT, externalId).
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "BLOOD_GLUCOSE", externalId: "sensor-1" },
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
externalId: "sensor-1",
|
||||
measuredAt: new Date("2024-04-03T02:15:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
@@ -513,6 +521,47 @@ describe("POST /api/import/csv — re-import", () => {
|
||||
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-folds the day a moved reading left as well as the day it moved to", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([stored("ext-1")]);
|
||||
await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,80.5,kg,2026-05-03T08:00:00Z,,after run,ext-1"].join(
|
||||
"\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
const days = vi
|
||||
.mocked(recomputeBucketsForMeasurement)
|
||||
.mock.calls.map(
|
||||
([, type, at]) => `${type}@${at.toISOString().slice(0, 10)}`,
|
||||
)
|
||||
.sort();
|
||||
expect(days).toEqual(["WEIGHT@2026-05-01", "WEIGHT@2026-05-03"]);
|
||||
});
|
||||
|
||||
it("re-folds what it touched when a write chunk fails part-way", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([]);
|
||||
mMeasurement().createManyAndReturn.mockRejectedValue(
|
||||
new Error("connection reset"),
|
||||
);
|
||||
await expect(
|
||||
POST(
|
||||
csvRequest(
|
||||
[
|
||||
HEADER,
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
|
||||
"WEIGHT,80.7,kg,2026-05-02T08:00:00Z,,,",
|
||||
].join("\n"),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("connection reset");
|
||||
const days = vi
|
||||
.mocked(recomputeBucketsForMeasurement)
|
||||
.mock.calls.map(([, , at]) => at.toISOString().slice(0, 10))
|
||||
.sort();
|
||||
expect(days).toEqual(["2026-05-01", "2026-05-02"]);
|
||||
});
|
||||
|
||||
it("probes per type in bounded chunks and writes updates in bounded batches", async () => {
|
||||
const lines = Array.from({ length: 2500 }, (_, i) => {
|
||||
const at = new Date(Date.UTC(2026, 0, 1) + i * 60_000).toISOString();
|
||||
|
||||
@@ -284,6 +284,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
// An update that moves the reading to another instant leaves the day
|
||||
// it came from: that day is re-folded too, or the old day keeps a
|
||||
// reading that no longer exists there.
|
||||
if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) {
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: stored.measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- externalId-less rows: create-only, deduped on the natural unique key
|
||||
@@ -353,83 +362,90 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
|
||||
const rows = await prisma.measurement.createManyAndReturn({
|
||||
data: chunk,
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
createdRows.push(...rows);
|
||||
createdCount += rows.length;
|
||||
}
|
||||
for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) {
|
||||
await prisma.$transaction(
|
||||
chunk.map((m) =>
|
||||
prisma.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
// The re-fold runs whatever happens to the writes. A chunk that fails
|
||||
// part-way leaves the earlier chunks written, and a retry reads those
|
||||
// rows as duplicates and touches nothing: without the re-fold here their
|
||||
// days would keep the rollups from before the import for good. Re-folding
|
||||
// a (type, day) nothing landed on is harmless.
|
||||
try {
|
||||
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
|
||||
const rows = await prisma.measurement.createManyAndReturn({
|
||||
data: chunk,
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
createdRows.push(...rows);
|
||||
createdCount += rows.length;
|
||||
}
|
||||
for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) {
|
||||
await prisma.$transaction(
|
||||
chunk.map((m) =>
|
||||
prisma.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
createdRows,
|
||||
"csv_import",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
createdRows
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
createdRows,
|
||||
"csv_import",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
createdRows
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
// Reconcile `inserted` against what `createMany` actually wrote
|
||||
// (mirrors the batch route's raced-duplicate downgrade). Under
|
||||
// `skipDuplicates` a conflicting row is silently absorbed — the key is
|
||||
// present in the table either way, so we cannot identify the SPECIFIC
|
||||
// raced lines; we only need the counters and per-line statuses to sum
|
||||
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
|
||||
// the envelope matches the DB write count.
|
||||
const racedDuplicates = toCreate.length - createdCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const [line, outcome] of writeOutcome) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (outcome === "inserted") {
|
||||
writeOutcome.set(line, "duplicate");
|
||||
inserted--;
|
||||
skipped++;
|
||||
downgraded++;
|
||||
// Reconcile `inserted` against what `createMany` actually wrote
|
||||
// (mirrors the batch route's raced-duplicate downgrade). Under
|
||||
// `skipDuplicates` a conflicting row is silently absorbed — the key is
|
||||
// present in the table either way, so we cannot identify the SPECIFIC
|
||||
// raced lines; we only need the counters and per-line statuses to sum
|
||||
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
|
||||
// the envelope matches the DB write count.
|
||||
const racedDuplicates = toCreate.length - createdCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const [line, outcome] of writeOutcome) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (outcome === "inserted") {
|
||||
writeOutcome.set(line, "duplicate");
|
||||
inserted--;
|
||||
skipped++;
|
||||
downgraded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row
|
||||
// CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks
|
||||
// — and the status re-warm for the touched types. Best-effort: a
|
||||
// populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
|
||||
}
|
||||
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks — and
|
||||
// the status re-warm for the touched types. Best-effort: a populator
|
||||
// hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
|
||||
|
||||
await auditLog("import.csv.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -29,6 +29,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
parseReminderAnchor,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
@@ -141,9 +142,12 @@ export const PATCH = apiHandler(
|
||||
updateData.intervalDays = null;
|
||||
}
|
||||
}
|
||||
const timezone = await resolveTimezone(user.id);
|
||||
if (data.anchorDate !== undefined) {
|
||||
updateData.anchorDate =
|
||||
data.anchorDate != null ? new Date(data.anchorDate) : null;
|
||||
data.anchorDate != null
|
||||
? parseReminderAnchor(data.anchorDate, timezone)
|
||||
: null;
|
||||
}
|
||||
if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour;
|
||||
if (data.location !== undefined) updateData.location = data.location;
|
||||
@@ -166,7 +170,6 @@ export const PATCH = apiHandler(
|
||||
// auto-clear a few lines up — so keying off its own keys (present, even
|
||||
// when the value is `null`) can never diverge from what the database
|
||||
// ends up holding.
|
||||
const timezone = await resolveTimezone(user.id);
|
||||
const now = new Date();
|
||||
const merged: ReminderScheduleInput = {
|
||||
intervalDays: Object.hasOwn(updateData, "intervalDays")
|
||||
@@ -193,10 +196,10 @@ export const PATCH = apiHandler(
|
||||
// open, overdue check-up quietly moved it to its next slot. Compared by
|
||||
// value, because a client may resend every field on each save.
|
||||
//
|
||||
// The first due date is compared by CALENDAR DAY in the profile zone. The
|
||||
// web form sends a date as the browser's local midnight, and a booster
|
||||
// row stores the dose instant plus N days, so the same day arrives as a
|
||||
// different instant. A resent same-day anchor is dropped from the write
|
||||
// The first due date is compared by CALENDAR DAY in the profile zone. A
|
||||
// client may send a date-time (older web builds sent the browser's local
|
||||
// midnight), and a booster row stores the dose instant plus N days, so the
|
||||
// same day arrives as a different instant. A resent same-day anchor is dropped from the write
|
||||
// altogether, so the stored instant stays what it was.
|
||||
const sameInstant = (a: Date | null, b: Date | null) =>
|
||||
(a?.getTime() ?? null) === (b?.getTime() ?? null);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
parseReminderAnchor,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
@@ -88,7 +89,10 @@ async function postReminder(request: NextRequest): Promise<Response> {
|
||||
|
||||
const timezone = await resolveTimezone(user.id);
|
||||
const now = new Date();
|
||||
const anchorDate = data.anchorDate != null ? new Date(data.anchorDate) : null;
|
||||
const anchorDate =
|
||||
data.anchorDate != null
|
||||
? parseReminderAnchor(data.anchorDate, timezone)
|
||||
: null;
|
||||
|
||||
// Server-authoritative next-due. No satisfy yet → anchors on
|
||||
// anchorDate ?? createdAt (createdAt ≈ now for a fresh row).
|
||||
|
||||
Reference in new issue
Block a user