fix(import): derive stable legacy mood timestamps

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 13:25:13 +02:00
1 parent 9484240688
commit 8b7cbddd72
2 files changed
+150 -81

No files matched your search

+64 -29
View File
@@ -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
View File
@@ -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