diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 5281f0cd4..6f464c049 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -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(); @@ -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"), diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 59301acd9..29915dd52 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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; + +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, +): 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(); + const reservedFallbackInstants = new Set(); 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