mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(import): derive stable legacy mood timestamps
This commit is contained in:
1 parent
9484240688
commit
8b7cbddd72
2 files changed
+150
-81
No files matched your search
@@ -18,7 +18,6 @@ vi.mock("@/lib/db", () => ({
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
moodEntry: { create: vi.fn(), upsert: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -78,9 +77,6 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(
|
||||
(operation) => operation(prisma as never) as never,
|
||||
);
|
||||
vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined);
|
||||
vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined);
|
||||
encryptNoteMock.mockImplementation((note: string | null) =>
|
||||
@@ -781,7 +777,7 @@ describe("POST /api/import — write failure classification", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back a partial legacy mood import before a retry", async () => {
|
||||
it("resumes a partially committed legacy mood import without duplicates", async () => {
|
||||
type LegacyMoodWrite = { date: string; moodLoggedAt: Date };
|
||||
|
||||
const committedKeys = new Set<string>();
|
||||
@@ -813,20 +809,6 @@ describe("POST /api/import — write failure classification", () => {
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) =>
|
||||
writeMood(data as LegacyMoodWrite, committedKeys),
|
||||
);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (operation) => {
|
||||
const pendingKeys = new Set(committedKeys);
|
||||
const result = await operation({
|
||||
moodEntry: {
|
||||
create: vi.fn(({ data }: { data: LegacyMoodWrite }) =>
|
||||
writeMood(data, pendingKeys),
|
||||
),
|
||||
upsert: prisma.moodEntry.upsert,
|
||||
},
|
||||
} as never);
|
||||
committedKeys.clear();
|
||||
for (const key of pendingKeys) committedKeys.add(key);
|
||||
return result as never;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
moodEntries: [
|
||||
@@ -839,16 +821,16 @@ describe("POST /api/import — write failure classification", () => {
|
||||
expect(failedResponse.status).toBe(503);
|
||||
expectRetryableFailure(await failedResponse.json(), {
|
||||
measurements: 0,
|
||||
moodEntries: 0,
|
||||
moodEntries: 1,
|
||||
skipped: 0,
|
||||
});
|
||||
expect(committedKeys.size).toBe(0);
|
||||
expect(committedKeys.size).toBe(1);
|
||||
|
||||
const retryResponse = await POST(request(payload));
|
||||
|
||||
expect(retryResponse.status).toBe(200);
|
||||
expect(await retryResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 2, skipped: 0 },
|
||||
data: { measurements: 0, moodEntries: 1, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
expect(committedKeys.size).toBe(2);
|
||||
@@ -899,28 +881,81 @@ describe("POST /api/import — write failure classification", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the current instant for a date-only mood row", async () => {
|
||||
it("anchors a date-only mood row to its recorded day", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const now = new Date("2026-05-10T08:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
moodEntries: [{ date: "2026-05-10", mood: "GUT", score: 4 }],
|
||||
moodEntries: [{ date: "2026-05-01", mood: "GUT", score: 4 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.moodEntry.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ moodLoggedAt: now }),
|
||||
});
|
||||
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
|
||||
.data.moodLoggedAt;
|
||||
expect(moodLoggedAt.toISOString().slice(0, 10)).toBe("2026-05-01");
|
||||
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
Date.parse("2026-05-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(moodLoggedAt.getTime()).toBeLessThan(
|
||||
Date.parse("2026-05-01T00:05:00.000Z"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a generated timestamp stable when upserting an external ID", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
const payload = {
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
externalId: "legacy-row-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await POST(request(payload));
|
||||
vi.advanceTimersByTime(1000);
|
||||
await POST(request(payload));
|
||||
|
||||
const first = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
const second = vi.mocked(prisma.moodEntry.upsert).mock.calls[1][0];
|
||||
expect(first.create.moodLoggedAt).toEqual(second.create.moodLoggedAt);
|
||||
expect(first.update.moodLoggedAt).toEqual(second.update.moodLoggedAt);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["1899-12-31", "2026-05-11"])(
|
||||
"rejects an implausible date-only mood row dated %s",
|
||||
async (date) => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
|
||||
const response = await POST(
|
||||
request({ moodEntries: [{ date, mood: "GUT", score: 4 }] }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(prisma.moodEntry.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("returns a retryable failure for a generic mood write error", async () => {
|
||||
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
|
||||
new Error("write failed"),
|
||||
|
||||
+86
-52
@@ -11,13 +11,18 @@ import {
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
validateMeasurementRange,
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import {
|
||||
ENTRY_INSTANT_CLOCK_SKEW_MS,
|
||||
isPlausibleEntryInstant,
|
||||
validateEntryInstant,
|
||||
} from "@/lib/validations/entry-instant";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -60,22 +65,71 @@ const measurementSchema = z
|
||||
"Wert ausserhalb des plausiblen Bereichs",
|
||||
);
|
||||
|
||||
const moodEntrySchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
|
||||
score: z.number().int().min(1).max(5),
|
||||
tags: z.string().optional(),
|
||||
// v1.17.1 — same entry-instant bound as measurements; an imported mood
|
||||
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
|
||||
loggedAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
).optional(),
|
||||
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
|
||||
// present, the import upserts on `(userId, source, externalId)` so a
|
||||
// re-import of the same export is idempotent rather than minting a
|
||||
// duplicate; absent → the legacy first-write-wins create path.
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
});
|
||||
const moodEntrySchema = z
|
||||
.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
|
||||
score: z.number().int().min(1).max(5),
|
||||
tags: z.string().optional(),
|
||||
// v1.17.1 — same entry-instant bound as measurements; an imported mood
|
||||
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
|
||||
loggedAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
).optional(),
|
||||
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
|
||||
// present, the import upserts on `(userId, source, externalId)` so a
|
||||
// re-import of the same export is idempotent rather than minting a
|
||||
// duplicate; absent → the legacy first-write-wins create path.
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
})
|
||||
.superRefine((entry, ctx) => {
|
||||
if (entry.loggedAt) return;
|
||||
const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`);
|
||||
const isCalendarDate =
|
||||
!Number.isNaN(dateStart) &&
|
||||
new Date(dateStart).toISOString().slice(0, 10) === entry.date;
|
||||
if (
|
||||
!isCalendarDate ||
|
||||
!isPlausibleEntryInstant(new Date(dateStart))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["date"],
|
||||
message: "Mood date must be between 1900 and today",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type MoodEntryInput = z.infer<typeof moodEntrySchema>;
|
||||
|
||||
function fallbackMoodIdentity(entry: MoodEntryInput): string {
|
||||
return JSON.stringify([
|
||||
entry.date,
|
||||
entry.mood,
|
||||
entry.score,
|
||||
entry.tags ?? null,
|
||||
entry.externalId ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function stableFallbackMoodInstant(
|
||||
entry: MoodEntryInput,
|
||||
occurrence: number,
|
||||
reservedInstants: Set<number>,
|
||||
): Date {
|
||||
const digest = createHash("sha256")
|
||||
.update(`${fallbackMoodIdentity(entry)}:${occurrence}`)
|
||||
.digest();
|
||||
let offset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`);
|
||||
let instant = dateStart + offset;
|
||||
while (reservedInstants.has(instant)) {
|
||||
offset = (offset + 1) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
instant = dateStart + offset;
|
||||
}
|
||||
reservedInstants.add(instant);
|
||||
return new Date(instant);
|
||||
}
|
||||
|
||||
const importSchema = z.object({
|
||||
measurements: z.array(measurementSchema).max(10000).optional(),
|
||||
@@ -238,18 +292,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
// Import mood entries with stable identities first. Rows without either
|
||||
// identity are committed together so a retry cannot replay a partial batch.
|
||||
// Import mood entries
|
||||
if (!writeFailed && data.moodEntries?.length) {
|
||||
const retryUnsafeEntries = [];
|
||||
const fallbackOccurrences = new Map<string, number>();
|
||||
const reservedFallbackInstants = new Set<number>();
|
||||
for (const e of data.moodEntries) {
|
||||
if (!e.loggedAt && !e.externalId) {
|
||||
retryUnsafeEntries.push(e);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const loggedAt = e.loggedAt ?? new Date();
|
||||
let loggedAt = e.loggedAt;
|
||||
if (!loggedAt) {
|
||||
const identity = fallbackMoodIdentity(e);
|
||||
const occurrence = fallbackOccurrences.get(identity) ?? 0;
|
||||
fallbackOccurrences.set(identity, occurrence + 1);
|
||||
loggedAt = stableFallbackMoodInstant(
|
||||
e,
|
||||
occurrence,
|
||||
reservedFallbackInstants,
|
||||
);
|
||||
}
|
||||
|
||||
if (e.externalId) {
|
||||
// v1.12.1 — idempotent re-import keyed on the source-stable id.
|
||||
// A second import of the same export updates the row in place
|
||||
@@ -304,32 +364,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!writeFailed && retryUnsafeEntries.length > 0) {
|
||||
const loggedAtBase = Date.now();
|
||||
let importedMoodEntries = 0;
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [index, e] of retryUnsafeEntries.entries()) {
|
||||
await tx.moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: new Date(loggedAtBase + index),
|
||||
},
|
||||
});
|
||||
importedMoodEntries++;
|
||||
}
|
||||
});
|
||||
stats.moodEntries += importedMoodEntries;
|
||||
} catch {
|
||||
writeFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than
|
||||
|
||||
Reference in new issue
Block a user