fix(db): live-row partial unique for intake re-take, mood external-id dedup key, perf indexes

Migration 0121 replaces the MedicationIntakeEvent unique constraint with a
partial unique index `WHERE deleted_at IS NULL`. A slot the user previously
deleted (tombstoned) no longer occupies the unique slot, so a re-take
re-creates the row cleanly instead of P2002-ing against the tombstone and
500-ing. The migration dedups any pre-existing duplicate live rows first
(keep newest, tombstone the rest) so the unique build can't fail on real
data.

Measurement and Workout deliberately stay full uniques: Measurement's
compound-key writes use prisma.upsert, which Prisma 7 compiles to native
INSERT ... ON CONFLICT and Postgres cannot arbiter against a partial unique
(and consolidate-daily-mean intentionally resurrects a tombstoned canonical
row, which needs the full unique); Workout has no deleted_at column. The
rationale is documented in the migration header and the schema comments.

Migration 0122 adds MoodEntry.externalId plus a NULL-distinct
`@@unique([userId, source, externalId])`. The moodLog webhook, the pull sync,
and the JSON import now carry the source's stable id into externalId and
upsert on the new key when present, so a re-import is idempotent even when
moodLoggedAt re-rounds or re-zones. Native/MANUAL entries keep their NULL
externalId and stay on the legacy (userId, date, moodLoggedAt) path.

Migration 0123 adds AuditLog(userId, action, createdAt desc) and
MoodEntry(userId, moodLoggedAt) composite indexes for the status-cache and
mood-insights read paths.

Also corrects the stale "v1.6.0 drops this column" note on
MedicationSchedule.daysOfWeek (the column is still dual-written and read by
live paths, so it is retained) and documents the UTC-only timezone storage
invariant in a schema header comment.

Integration tests cover the intake re-take after delete (no resurrection /
no 500) and mood re-import idempotency (with and without an external id) on
real Postgres.

(cherry picked from commit 9a6723f469bdec063a99cd08df132e07ec3c95a5)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 03:01:36 +02:00
1 parent 37e9f32f56
commit 9c4e4de927
9 files changed
+640 -37

No files matched your search

+49 -11
View File
@@ -48,6 +48,11 @@ const moodEntrySchema = z.object({
score: z.number().int().min(1).max(5),
tags: z.string().optional(),
loggedAt: z.string().datetime().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 importSchema = z.object({
@@ -129,17 +134,50 @@ export const POST = apiHandler(async (request: NextRequest) => {
for (const e of data.moodEntries) {
try {
const loggedAt = e.loggedAt ? new Date(e.loggedAt) : new Date();
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
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,
},
});
} 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++;
} catch {
// Unique constraint violation — skip duplicate
@@ -95,18 +95,54 @@ export const POST = apiHandler(async (request: NextRequest) => {
// 5. Process event (non-blocking — fire and forget with error logging)
const moodLoggedAt = new Date(entry.time);
// v1.12.1 — when the upstream supplies a stable entry id, dedup on
// `(userId, source, externalId)` so a re-emit with a re-rounded /
// re-zoned `time` updates the same row instead of minting a duplicate.
// Absent → the legacy `(userId, date, moodLoggedAt)` key. MOODLOG is
// the fixed source here, matching the create/update writes below.
const externalId = entry.id ?? null;
try {
if (event === "mood.deleted") {
await prisma.moodEntry.deleteMany({
where: {
userId: user.id,
...(externalId
? { source: "MOODLOG", externalId }
: { date: entry.date, moodLoggedAt }),
},
});
} else if (externalId) {
// mood.created or mood.updated, keyed on the stable external id.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId: user.id,
source: "MOODLOG",
externalId,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
date: entry.date,
moodLoggedAt,
},
create: {
userId: user.id,
date: entry.date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
externalId,
moodLoggedAt,
},
});
} else {
// mood.created or mood.updated
// mood.created or mood.updated, legacy wall-clock key.
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
+60 -21
View File
@@ -211,6 +211,7 @@ export async function syncMoodLogEntries(
let echoSkipped = 0;
for (const e of entries) {
const entry = e as {
id?: string;
time: string;
date: string;
mood: string;
@@ -225,31 +226,69 @@ export async function syncMoodLogEntries(
try {
const moodLoggedAt = new Date(entry.time);
const date = entry.date;
// v1.12.1 — when the upstream supplies a stable id, upsert on
// `(userId, source, externalId)` so a re-pull with a re-rounded /
// re-zoned `time` is idempotent; otherwise the legacy wall-clock
// key. The id is bounded to the column size to match the webhook.
const externalId =
typeof entry.id === "string" && entry.id.length > 0
? entry.id.slice(0, 120)
: null;
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
if (externalId) {
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
});
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
externalId,
moodLoggedAt,
},
});
} else {
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
});
}
imported++;
} catch (err) {
getEvent()?.addWarning(`Failed to upsert entry: ${err}`);
+9
View File
@@ -29,6 +29,12 @@ export const moodLogWebhookPayloadSchema = z.object({
event: z.enum(["mood.created", "mood.updated", "mood.deleted"]),
timestamp: z.string().datetime(),
entry: z.object({
// v1.12.1 — optional source-stable entry id. When present, the
// webhook dedups on `(userId, source, externalId)` so a re-emit with
// a re-rounded / re-zoned `time` is idempotent instead of minting a
// second row. Absent → the legacy `(userId, date, moodLoggedAt)`
// path. Bounded so a malformed upstream id can't bloat the column.
id: z.string().min(1).max(120).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().datetime(),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
@@ -42,6 +48,9 @@ export const moodLogSyncResponseSchema = z.object({
version: z.string(),
entries: z.array(
z.object({
// v1.12.1 — optional source-stable id, mirrors the webhook entry.
// Carried into `externalId` for idempotent re-import.
id: z.string().min(1).max(120).optional(),
date: z.string(),
time: z.string(),
mood: z.string(),