mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
37e9f32f56
commit
9c4e4de927
9 files changed
+640
-37
No files matched your search
+49
-11
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user