mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -0,0 +1,79 @@
|
||||
-- v1.12.1 — partial unique index scoped to live rows
|
||||
-- (`WHERE deleted_at IS NULL`) on MedicationIntakeEvent.
|
||||
--
|
||||
-- Problem. `MedicationIntakeEvent` carries a `deleted_at` tombstone, but
|
||||
-- its unique constraint (0073) covers every row regardless of tombstone
|
||||
-- state. A soft-deleted slot row therefore keeps occupying its unique
|
||||
-- slot, which breaks delete-then-retake: `applyCanonicalSlotWrite`
|
||||
-- (`src/lib/medications/scheduling/slot-upsert.ts`) finds no LIVE row for
|
||||
-- a slot the user previously deleted, tries to CREATE, and P2002s against
|
||||
-- the tombstone on `(user_id, medication_id, scheduled_for, source)`; the
|
||||
-- catch re-finds only live rows, finds none, and re-throws — the re-take
|
||||
-- 500s. The slot-dedup job already has to catch-and-skip this exact
|
||||
-- collision (`intake-slot-dedup.ts:257-298`).
|
||||
--
|
||||
-- Fix. Replace the full unique CONSTRAINT with a PARTIAL unique INDEX
|
||||
-- predicated on `deleted_at IS NULL`. Live-row uniqueness still holds;
|
||||
-- tombstones drop out of the index entirely, so a re-take re-creates the
|
||||
-- slot row cleanly instead of P2002-ing against the tombstone. Prisma
|
||||
-- can't express a partial-predicate unique, so this is hand-written (same
|
||||
-- reason as the step-consolidation / dense-intraday partial indexes in
|
||||
-- 0087 / 0108). The schema keeps the full `@@unique` (which generates the
|
||||
-- `userId_medicationId_scheduledFor_source` compound where-key the slot
|
||||
-- finder uses) with a comment pointing here.
|
||||
--
|
||||
-- Why intake and NOT Measurement / Workout here:
|
||||
-- * Measurement — its compound-key write path uses `prisma.upsert`
|
||||
-- (consolidate-daily-mean, drain-per-sample-cumulative, the Fitbit /
|
||||
-- WHOOP / Apple-Health sync, Apple-export import). Prisma 7 compiles
|
||||
-- a compound-key `upsert` to native `INSERT ... ON CONFLICT (cols)`,
|
||||
-- and Postgres rejects an `ON CONFLICT` arbiter that targets a
|
||||
-- PARTIAL unique without the matching predicate (which Prisma does
|
||||
-- not emit). Several of those upserts also intentionally RESURRECT a
|
||||
-- tombstoned canonical row (`update: { deletedAt: null }` in
|
||||
-- consolidate-daily-mean) — a behaviour that depends on the full
|
||||
-- unique covering tombstoned rows. Converting Measurement to a
|
||||
-- partial unique would break every one of those upserts. The
|
||||
-- Measurement batch-ingest resurrection gap is instead closed at the
|
||||
-- application layer (the existence probe filters `deletedAt: null`).
|
||||
-- * Workout — has no `deleted_at` column (workout deletes are hard
|
||||
-- deletes), so there is no tombstone to collide with.
|
||||
-- MedicationIntakeEvent has NO `upsert` call site (every writer is
|
||||
-- `create` / `createMany` + a P2002 catch, and `createMany skipDuplicates`
|
||||
-- compiles to `ON CONFLICT DO NOTHING` without a target, which a partial
|
||||
-- index satisfies), so the partial unique is safe here and nowhere else.
|
||||
--
|
||||
-- DEDUP-FIRST. A partial unique build fails if two LIVE rows already
|
||||
-- share the tuple. Before the `CREATE UNIQUE INDEX` the migration
|
||||
-- collapses any existing duplicate live rows, keeping the newest
|
||||
-- (`updated_at` then `id` as the tie-break) and tombstoning the rest
|
||||
-- (soft-delete, not hard-delete, so the audit/sync trail is preserved and
|
||||
-- a paired client still reconciles the collapse). Idempotent: the dedup
|
||||
-- is a no-op on a clean table and the index builds with `IF NOT EXISTS`.
|
||||
--
|
||||
-- Lock note (conscious trade-off): this is a plain, non-`CONCURRENTLY`
|
||||
-- index build, so it runs inside Prisma's migration transaction and
|
||||
-- briefly holds ACCESS EXCLUSIVE on the table. The duplicate-live-row
|
||||
-- DELETE-then-CREATE pair is sub-second on every normal deployment.
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "user_id", "medication_id", "scheduled_for", "source"
|
||||
ORDER BY "updated_at" DESC, "id" DESC
|
||||
) AS rn
|
||||
FROM "medication_intake_events"
|
||||
WHERE "deleted_at" IS NULL
|
||||
)
|
||||
UPDATE "medication_intake_events" e
|
||||
SET "deleted_at" = NOW(), "sync_version" = e."sync_version" + 1
|
||||
FROM ranked r
|
||||
WHERE e."id" = r."id" AND r.rn > 1;
|
||||
|
||||
ALTER TABLE "medication_intake_events"
|
||||
DROP CONSTRAINT IF EXISTS "medication_intake_events_user_id_medication_id_scheduled_for_source_key";
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "medication_intake_events_user_med_slot_source_live_key"
|
||||
ON "medication_intake_events" ("user_id", "medication_id", "scheduled_for", "source")
|
||||
WHERE "deleted_at" IS NULL;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- v1.12.1 — stable external dedup key for MoodEntry.
|
||||
--
|
||||
-- Problem. Unlike `Measurement`, `MoodEntry` carries no `external_id`.
|
||||
-- The moodLog webhook + pull sync + JSON import all upsert/insert on
|
||||
-- `(user_id, date, mood_logged_at)`. If the upstream re-emits the same
|
||||
-- entry with a re-rounded or re-zoned `mood_logged_at` (a Daylio export
|
||||
-- re-import, or a tz change moving `date`), the composite key differs
|
||||
-- and a SECOND row lands for the same physical mood entry. Measurements
|
||||
-- solved exactly this with `external_id`; mood did not.
|
||||
--
|
||||
-- Fix. Add a nullable `external_id` column + a NULL-DISTINCT unique on
|
||||
-- `(user_id, source, external_id)`. Postgres treats NULLs as distinct in
|
||||
-- a unique index by default, so every MANUAL/native entry (which carries
|
||||
-- a NULL `external_id`) never collides on the new key and keeps the
|
||||
-- legacy `(user_id, date, mood_logged_at)` path. Importers that receive
|
||||
-- a source-stable id (the moodLog upstream entry id, a Daylio row id)
|
||||
-- write it into `external_id` and upsert on the new key, making a
|
||||
-- re-import idempotent regardless of `mood_logged_at` drift.
|
||||
--
|
||||
-- Strictly additive: a new nullable column + a NULL-distinct unique
|
||||
-- index. No backfill (legacy rows stay NULL → distinct → untouched), no
|
||||
-- rewrite of `mood_entries`.
|
||||
|
||||
ALTER TABLE "mood_entries"
|
||||
ADD COLUMN IF NOT EXISTS "external_id" TEXT;
|
||||
|
||||
-- NULL-distinct unique: only entries that carry a real source id are
|
||||
-- constrained; the long tail of native/MANUAL rows (NULL external_id)
|
||||
-- never collides here.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "mood_entries_user_id_source_external_id_key"
|
||||
ON "mood_entries" ("user_id", "source", "external_id");
|
||||
@@ -0,0 +1,26 @@
|
||||
-- v1.12.1 — composite read-path indexes.
|
||||
--
|
||||
-- P1-2. `AuditLog(user_id, action, created_at desc)`.
|
||||
-- `status-cache.ts` filters `AuditLog` on `{ userId, action }` ordered
|
||||
-- by `created_at desc` on every insight-status card mount (7 generators).
|
||||
-- The table only had `(user_id, created_at)` and `(action, created_at)`,
|
||||
-- so the planner picked one prefix and re-filtered + re-sorted the other
|
||||
-- column over the highest-churn table in the app. The three-column index
|
||||
-- turns both the `findFirst` and the `take:5` into an index-only range
|
||||
-- scan.
|
||||
--
|
||||
-- P1-3. `MoodEntry(user_id, mood_logged_at)`.
|
||||
-- `mood-aggregates.ts` + `mood-status.ts` query `MoodEntry` by
|
||||
-- `{ userId, deletedAt: null, moodLoggedAt: { gte } }` ordered by
|
||||
-- `mood_logged_at desc`. No existing index leads with
|
||||
-- `(user_id, mood_logged_at)`; the planner used the `user_id` prefix then
|
||||
-- sorted. Additive, low risk.
|
||||
--
|
||||
-- Both are pure `CREATE INDEX IF NOT EXISTS` — additive, no column add,
|
||||
-- no backfill.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "audit_logs_user_id_action_created_at_idx"
|
||||
ON "audit_logs" ("user_id", "action", "created_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_mood_logged_at_idx"
|
||||
ON "mood_entries" ("user_id", "mood_logged_at");
|
||||
+82
-4
@@ -1,5 +1,29 @@
|
||||
// v1.4.34 web-freeze. Schema is locked additive-only until iOS launch
|
||||
// + v1.5.0 marker. See .planning/v15-strategic-plan.md §2.
|
||||
//
|
||||
// ─── Timezone storage convention (UTC-only invariant) ───────────────
|
||||
//
|
||||
// The database is UTC-only. Almost every `DateTime` column is the
|
||||
// Prisma default `timestamp(3)` WITHOUT time zone; a handful of rollup
|
||||
// columns (`MeasurementRollup.bucketStart` / `computedAt`, and the mood
|
||||
// rollup buckets) carry `@db.Timestamptz(3)`. This is NOT a live bug:
|
||||
// Prisma always reads and writes UTC over the wire, so both types
|
||||
// round-trip the same instant. The invariant every reader, writer, and
|
||||
// raw-SQL query MUST hold is:
|
||||
//
|
||||
// * The application server and the Postgres session run in UTC.
|
||||
// * Wall-clock / calendar-local anchoring lives in an explicit `tz`
|
||||
// string column (e.g. `MoodEntry.tz`) or the user's `timezone`, NOT
|
||||
// in the column type.
|
||||
// * Raw SQL that does date math on a `timestamp` column treats the
|
||||
// stored value as UTC; never compare a bare `timestamp` against
|
||||
// `now()`/`current_timestamp` with the expectation of a zone offset.
|
||||
//
|
||||
// Do NOT do a `timestamp` → `timestamptz` type sweep ad hoc — the mixed
|
||||
// types are harmless under the UTC invariant, and a partial sweep is
|
||||
// riskier than the documented convention. If a server-region move is
|
||||
// ever on the table, convert the whole schema in one audited migration
|
||||
// (`ALTER ... TYPE timestamptz USING ... AT TIME ZONE 'UTC'`).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
@@ -783,6 +807,19 @@ model Measurement {
|
||||
// for every meastype other than SLEEP_DURATION. Future regenerations
|
||||
// must preserve the NULLS NOT DISTINCT clause (Prisma 7 still emits
|
||||
// NULLS DISTINCT by default).
|
||||
// v1.12.1 — these stay FULL uniques (not partial live-row uniques).
|
||||
// The compound-key write paths (consolidate-daily-mean,
|
||||
// drain-per-sample-cumulative, the Fitbit / WHOOP / Apple sync +
|
||||
// import) use `prisma.upsert`, which Prisma 7 compiles to native
|
||||
// `INSERT ... ON CONFLICT (cols)`; Postgres cannot arbiter an
|
||||
// `ON CONFLICT` against a partial unique without the predicate (which
|
||||
// Prisma does not emit), and consolidate-daily-mean deliberately
|
||||
// RESURRECTS a tombstoned canonical row via `update: { deletedAt:
|
||||
// null }`, which needs the unique to cover tombstones. So a partial
|
||||
// unique is unsafe here. The batch-ingest resurrection gap is closed
|
||||
// instead at the application layer (the existence probe filters
|
||||
// `deletedAt: null`). Contrast MedicationIntakeEvent, whose write path
|
||||
// is `create`-only and DID get the partial-unique treatment (0121).
|
||||
@@unique([userId, type, measuredAt, source, sleepStage])
|
||||
// v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct
|
||||
// (Postgres semantics) so manual entries don't collide here.
|
||||
@@ -937,6 +974,10 @@ model Workout {
|
||||
/// batch + the other's from Tuesday's backfill) so the dashboard
|
||||
/// "recent workouts" tile shows one row per logical workout.
|
||||
/// Both pickers resolve the user's `sourcePriorityJson` ladder.
|
||||
/// Note (v1.12.1): unlike Measurement / MedicationIntakeEvent, Workout
|
||||
/// has no `deletedAt` (workout deletes are hard deletes), so this stays
|
||||
/// a full unique — there is no tombstone to collide with and no
|
||||
/// resurrection gap to close.
|
||||
@@unique([userId, source, externalId])
|
||||
/// Dashboard "recent workouts" query path.
|
||||
@@index([userId, startedAt])
|
||||
@@ -1262,10 +1303,18 @@ model MedicationSchedule {
|
||||
label String? // e.g. "Morning", "Evening"
|
||||
dose String? // Per-schedule dose override; null = use Medication.dose
|
||||
/// Legacy string encoding of the cadence ("null | 1,3,5 | i2;1,3,5").
|
||||
/// Kept for reads of pre-v1.5 rows. v1.5 writes go to `rrule` or
|
||||
/// `rollingIntervalDays`; the migration backfills `rrule` for every
|
||||
/// existing row and v1.5.1 flips the readers to consult the new
|
||||
/// fields first. v1.6.0 drops this column.
|
||||
/// v1.5 writes go to `rrule` / `rollingIntervalDays` and v1.5.1 flipped
|
||||
/// the readers to consult the new fields first.
|
||||
///
|
||||
/// v1.12.1 — RETAINED, not dropped. The "v1.6.0 drops this column"
|
||||
/// note here was never executed and is now corrected: the column is
|
||||
/// still DUAL-WRITTEN on every create/update (the medications POST /
|
||||
/// PATCH routes serialise the legacy `(daysOfWeek, intervalWeeks)`
|
||||
/// pair via `serializeScheduleRecurrence`) AND still read by live paths
|
||||
/// (`gamification/achievements`, `admin/notifications/reminder-check`,
|
||||
/// the medications list/detail pages parse it through
|
||||
/// `parseScheduleRecurrence`). Dropping it would break those callers,
|
||||
/// so it stays until those readers are migrated off the legacy pair.
|
||||
daysOfWeek String? @map("days_of_week")
|
||||
/// v1.5 — one or more `HH:mm` strings naming the times the dose is
|
||||
/// taken (in the user's timezone). Replaces the legacy single
|
||||
@@ -1403,6 +1452,13 @@ model MedicationIntakeEvent {
|
||||
/// existence probe and the insert. `source` is in the key so the
|
||||
/// reminder worker (REMINDER) doesn't collide with manual user
|
||||
/// inserts (WEB / TELEGRAM / IMPORT) on the same slot.
|
||||
/// v1.12.1 — this is a partial live-row unique in the DB (`WHERE
|
||||
/// deleted_at IS NULL`, migration 0121, replacing the 0073 table
|
||||
/// constraint). A slot the user previously deleted (tombstoned) no
|
||||
/// longer blocks a re-take: `applyCanonicalSlotWrite` can re-create the
|
||||
/// row instead of P2002-ing against the tombstone. Schema keeps the
|
||||
/// full `@@unique` for the `userId_medicationId_scheduledFor_source`
|
||||
/// compound where-key.
|
||||
@@unique([userId, medicationId, scheduledFor, source])
|
||||
@@index([userId, medicationId, scheduledFor])
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
|
||||
@@ -1902,6 +1958,15 @@ model MoodEntry {
|
||||
/// rows into this column via the migration script.
|
||||
note String? @db.Text
|
||||
source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO
|
||||
// v1.12.1 — stable per-source dedup id. NULL for native / MANUAL
|
||||
// entries (those dedup on the legacy `(userId, date, moodLoggedAt)`
|
||||
// key). When an importer receives a source-stable id (the moodLog
|
||||
// upstream entry id, a Daylio row id) it writes it here and upserts on
|
||||
// `@@unique([userId, source, externalId])`, so a re-import is
|
||||
// idempotent even when `moodLoggedAt` re-rounds / re-zones. Postgres
|
||||
// treats NULLs as distinct in that unique, so MANUAL rows never
|
||||
// collide on it. See migration 0122.
|
||||
externalId String? @map("external_id")
|
||||
moodLoggedAt DateTime @map("mood_logged_at")
|
||||
/// v1.4.25 W7b — per-row IANA timezone that the `date` string is
|
||||
/// anchored to. Nullable for backward compatibility: legacy rows
|
||||
@@ -1937,7 +2002,15 @@ model MoodEntry {
|
||||
tagLinks MoodEntryTagLink[]
|
||||
|
||||
@@unique([userId, date, moodLoggedAt])
|
||||
// v1.12.1 — NULL-distinct external dedup key (migration 0122). Importers
|
||||
// that carry a source-stable id upsert on this; native/MANUAL entries
|
||||
// (NULL externalId) are distinct here and stay on the legacy key above.
|
||||
@@unique([userId, source, externalId])
|
||||
@@index([userId, date])
|
||||
// v1.12.1 (P1-3) — mood-insights reads filter `{ userId, deletedAt:
|
||||
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
|
||||
// index turns that into a range scan. See migration 0123.
|
||||
@@index([userId, moodLoggedAt])
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
@@ -2330,6 +2403,11 @@ model AuditLog {
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
// v1.12.1 (P1-2) — status-cache reads filter `{ userId, action }`
|
||||
// ordered by `createdAt desc` on every insight-status card mount;
|
||||
// this three-column index serves both the findFirst and the take:5 as
|
||||
// an index range scan over the highest-churn table. See migration 0123.
|
||||
@@index([userId, action, createdAt(sort: Desc)])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* v1.12.1 — partial live-row unique index on MedicationIntakeEvent
|
||||
* (migration 0121) + MoodEntry external-id dedup key (migration 0122).
|
||||
*
|
||||
* These pin the delete-then-resync / re-import contracts on real
|
||||
* Postgres, against the migrations replayed by `global-setup.ts`.
|
||||
*
|
||||
* 1. MedicationIntakeEvent — the real re-take path through
|
||||
* `applyCanonicalSlotWrite`: take a slot, soft-delete it (user
|
||||
* "forgot this"), then re-take the SAME canonical slot. Pre-0121
|
||||
* the create P2002'd against the tombstone and the catch re-found
|
||||
* only live rows (none) and re-threw — the re-take 500'd. With the
|
||||
* partial live-row unique it re-creates cleanly.
|
||||
*
|
||||
* 2. MoodEntry — a moodLog re-import with the SAME stable `id` but a
|
||||
* re-zoned `moodLoggedAt` updates one row (idempotent on
|
||||
* `(userId, source, externalId)`), while two imports WITHOUT an id
|
||||
* still land two rows on the legacy wall-clock key.
|
||||
*
|
||||
* Note: Measurement is intentionally NOT made partial-unique (its
|
||||
* compound-key writes use `prisma.upsert` → native ON CONFLICT, which
|
||||
* Postgres cannot arbiter against a partial unique). See migration 0121's
|
||||
* header for the full rationale.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { applyCanonicalSlotWrite } from "@/lib/medications/scheduling/slot-upsert";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("MedicationIntakeEvent — re-take after delete (v1.12.1 / 0121)", () => {
|
||||
it("re-takes a previously-deleted slot without 500-ing", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "partial-intake",
|
||||
email: "partial-intake@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const med = await prisma.medication.create({
|
||||
data: { userId: user.id, name: "Ramipril", dose: "5mg" },
|
||||
});
|
||||
|
||||
const slot = new Date("2026-06-03T06:00:00.000Z");
|
||||
|
||||
// First take — fresh slot.
|
||||
const firstTake = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
canonicalSlot: slot,
|
||||
takenAt: new Date("2026-06-03T06:05:00.000Z"),
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
});
|
||||
expect(firstTake.outcome).toBe("inserted");
|
||||
expect(firstTake.row.takenAt).not.toBeNull();
|
||||
|
||||
// User deletes the take (soft-delete the slot row).
|
||||
await prisma.medicationIntakeEvent.update({
|
||||
where: { id: firstTake.row.id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Re-take the SAME canonical slot. Pre-0121 this threw the original
|
||||
// P2002 (tombstone occupied (user, med, scheduled_for, source)).
|
||||
const reTake = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
canonicalSlot: slot,
|
||||
takenAt: new Date("2026-06-03T06:10:00.000Z"),
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
});
|
||||
expect(reTake.outcome).toBe("inserted");
|
||||
expect(reTake.row.id).not.toBe(firstTake.row.id);
|
||||
expect(reTake.row.takenAt).not.toBeNull();
|
||||
|
||||
// Exactly one LIVE slot row; the tombstone remains.
|
||||
const live = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: slot,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].id).toBe(reTake.row.id);
|
||||
|
||||
// The live-row partial unique still forbids a SECOND live duplicate
|
||||
// on the same (user, med, slot, source).
|
||||
await expect(
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: slot,
|
||||
source: "WEB",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () => {
|
||||
function webhookRequest(secret: string, body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/moodlog/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-webhook-secret": secret,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("re-import with the same stable id but re-zoned time updates one row", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-idempotent";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-external-id",
|
||||
email: "mood-external-id@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
// Legacy plaintext secret — readMoodLogSecret tolerates it.
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import(
|
||||
"@/app/api/integrations/moodlog/webhook/route"
|
||||
);
|
||||
|
||||
// First import — entry carries a stable upstream id.
|
||||
const r1 = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-04T10:00:00.000Z",
|
||||
entry: {
|
||||
id: "daylio-row-42",
|
||||
date: "2026-06-04",
|
||||
time: "2026-06-04T10:00:00.000Z",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r1.status).toBe(200);
|
||||
|
||||
// Re-import — SAME id, but a re-rounded / re-zoned time (and the
|
||||
// upstream re-scored the mood). Pre-0122 this minted a 2nd row on
|
||||
// the differing (date, moodLoggedAt) key.
|
||||
const r2 = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.updated",
|
||||
timestamp: "2026-06-04T10:30:00.000Z",
|
||||
entry: {
|
||||
id: "daylio-row-42",
|
||||
date: "2026-06-04",
|
||||
time: "2026-06-04T08:00:00.000Z",
|
||||
mood: "SUPER_GUT",
|
||||
score: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r2.status).toBe(200);
|
||||
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].externalId).toBe("daylio-row-42");
|
||||
// The update applied (mood + score + re-zoned time).
|
||||
expect(rows[0].mood).toBe("SUPER_GUT");
|
||||
expect(rows[0].score).toBe(5);
|
||||
});
|
||||
|
||||
it("entries without an id still dedup on the legacy wall-clock key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-legacy";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-legacy-key",
|
||||
email: "mood-legacy-key@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import(
|
||||
"@/app/api/integrations/moodlog/webhook/route"
|
||||
);
|
||||
|
||||
// Two entries, no id, DIFFERENT times → two rows (legacy behaviour
|
||||
// preserved). A re-emit at the SAME time updates in place.
|
||||
await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-05T09:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-06-05",
|
||||
time: "2026-06-05T09:00:00.000Z",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-05T21:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-06-05",
|
||||
time: "2026-06-05T21:00:00.000Z",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Re-emit the first at the exact same time → in-place update.
|
||||
await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.updated",
|
||||
timestamp: "2026-06-05T09:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-06-05",
|
||||
time: "2026-06-05T09:00:00.000Z",
|
||||
mood: "SUPER_GUT",
|
||||
score: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.every((r) => r.externalId === null)).toBe(true);
|
||||
// The morning row was updated in place by the same-time re-emit.
|
||||
expect(rows[0].mood).toBe("SUPER_GUT");
|
||||
expect(rows[1].mood).toBe("GUT");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user