diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 6f464c049..3fbc0ce56 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), upsert: vi.fn(), }, - moodEntry: { create: vi.fn(), upsert: vi.fn() }, + moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() }, }, })); @@ -77,6 +77,7 @@ import { auditLog } from "@/lib/auth/audit"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null); + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null); vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined); vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined); encryptNoteMock.mockImplementation((note: string | null) => @@ -809,6 +810,20 @@ describe("POST /api/import — write failure classification", () => { vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) => writeMood(data as LegacyMoodWrite, committedKeys), ); + vi.mocked(prisma.moodEntry.findUnique).mockImplementation( + async ({ where }) => { + const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`; + if (!committedKeys.has(key)) return null; + const isFirstDay = where.userId_date_moodLoggedAt.date === "2026-05-01"; + return { + source: "IMPORT", + mood: isFirstDay ? "GUT" : "OKAY", + score: isFirstDay ? 4 : 3, + tags: null, + externalId: null, + } as never; + }, + ); const payload = { moodEntries: [ @@ -896,18 +911,100 @@ describe("POST /api/import — write failure classification", () => { expect(response.status).toBe(200); 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"), + Date.parse("2026-04-30T22:00:00.000Z"), ); expect(moodLoggedAt.getTime()).toBeLessThan( - Date.parse("2026-05-01T00:05:00.000Z"), + Date.parse("2026-04-30T22:05:00.000Z"), ); } finally { vi.useRealTimers(); } }); + it("accepts the current Berlin day shortly after local midnight", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-10T22:30:00.000Z")); + vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never); + + const response = await POST( + request({ + moodEntries: [{ date: "2026-05-11", mood: "GUT", score: 4 }], + }), + ); + + expect(response.status).toBe(200); + const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0] + .data.moodLoggedAt; + expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual( + Date.parse("2026-05-10T22:00:00.000Z"), + ); + expect(moodLoggedAt.getTime()).toBeLessThan( + Date.parse("2026-05-10T22:05:00.000Z"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("probes past a persisted fallback timestamp collision", async () => { + type StoredMood = { + date: string; + mood: string; + score: number; + tags: string | null; + externalId?: string | null; + moodLoggedAt: Date; + }; + + const persisted = new Map(); + vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { + const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; + if (persisted.has(key)) { + throw new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["userId", "date", "moodLoggedAt"] }, + }, + ); + } + persisted.set(key, data as StoredMood); + return {} as never; + }); + vi.mocked(prisma.moodEntry.findUnique).mockImplementation(async ({ where }) => { + const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`; + return (persisted.get(key) ?? null) as never; + }); + + const firstPayload = { + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" }, + ], + }; + const collidingPayload = { + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" }, + ], + }; + + await POST(request(firstPayload)); + const collisionResponse = await POST(request(collidingPayload)); + const retryResponse = await POST(request(collidingPayload)); + + expect(await collisionResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 1, skipped: 0 }, + error: null, + }); + expect(await retryResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + expect(persisted.size).toBe(2); + }); + it("keeps a generated timestamp stable when upserting an external ID", async () => { vi.useFakeTimers(); try { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 29915dd52..43239448d 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -33,6 +33,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -65,6 +67,16 @@ const measurementSchema = z "Wert ausserhalb des plausiblen Bereichs", ); +function fallbackMoodDateStart(date: string): Date | null { + const [year, month, day] = date.split("-").map(Number); + if (!year || !month || !day) return null; + const instant = zonedWallClockToUtc( + { year, month, day, hour: 0, minute: 0 }, + DEFAULT_TIMEZONE, + ); + return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null; +} + const moodEntrySchema = z .object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -84,14 +96,8 @@ const moodEntrySchema = z }) .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)) - ) { + const dateStart = fallbackMoodDateStart(entry.date); + if (!dateStart || !isPlausibleEntryInstant(dateStart)) { ctx.addIssue({ code: "custom", path: ["date"], @@ -115,20 +121,18 @@ function fallbackMoodIdentity(entry: MoodEntryInput): string { function stableFallbackMoodInstant( entry: MoodEntryInput, occurrence: number, - reservedInstants: Set, + probe: 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 initialOffset = + digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const offset = + (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const dateStart = fallbackMoodDateStart(entry.date); + if (!dateStart) throw new Error("Invalid mood date"); + return new Date(dateStart.getTime() + offset); } const importSchema = z.object({ @@ -295,74 +299,116 @@ export const POST = apiHandler(async (request: NextRequest) => { // Import mood entries if (!writeFailed && data.moodEntries?.length) { const fallbackOccurrences = new Map(); - const reservedFallbackInstants = new Set(); for (const e of data.moodEntries) { - try { - 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, - ); - } + const identity = e.loggedAt ? null : fallbackMoodIdentity(e); + const occurrence = + identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0); + if (identity !== null) { + fallbackOccurrences.set(identity, occurrence + 1); + } - 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 - // rather than skipping it as a duplicate, so an upstream edit - // (re-scored mood, added tag) is reflected. - await prisma.moodEntry.upsert({ - where: { - userId_source_externalId: { + for ( + let probe = 0; + probe < ENTRY_INSTANT_CLOCK_SKEW_MS; + probe++ + ) { + const loggedAt = + e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe); + try { + 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 + // rather than skipping it as a duplicate, so an upstream edit + // (re-scored mood, added tag) is reflected. + await prisma.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId, + source: "IMPORT", + externalId: e.externalId, + }, + }, + update: { + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, + moodLoggedAt: loggedAt, + }, + create: { userId, + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, source: "IMPORT", externalId: e.externalId, + moodLoggedAt: loggedAt, }, - }, - update: { - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - moodLoggedAt: loggedAt, - }, - create: { - userId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - source: "IMPORT", - externalId: e.externalId, - moodLoggedAt: loggedAt, - }, - }); - } else { - await prisma.moodEntry.create({ - data: { - userId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - source: "IMPORT", - moodLoggedAt: loggedAt, - }, - }); + }); + } else { + await prisma.moodEntry.create({ + data: { + userId, + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, + source: "IMPORT", + moodLoggedAt: loggedAt, + }, + }); + } + stats.moodEntries++; + break; + } catch (err) { + if (!isP2002(err)) { + writeFailed = true; + break; + } + if (e.loggedAt) { + stats.skipped++; + break; + } + if (!e.externalId) { + try { + const existing = await prisma.moodEntry.findUnique({ + where: { + userId_date_moodLoggedAt: { + userId, + date: e.date, + moodLoggedAt: loggedAt, + }, + }, + select: { + source: true, + mood: true, + score: true, + tags: true, + externalId: true, + }, + }); + if ( + existing?.source === "IMPORT" && + existing.mood === e.mood && + existing.score === e.score && + existing.tags === (e.tags || null) && + (existing.externalId ?? null) === null + ) { + stats.skipped++; + break; + } + } catch { + writeFailed = true; + break; + } + } + if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) { + writeFailed = true; + } } - stats.moodEntries++; - } catch (err) { - if (isP2002(err)) { - stats.skipped++; - continue; - } - writeFailed = true; - break; } + if (writeFailed) break; } }