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

@@ -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
View File
@@ -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
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(),
@@ -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");
});
});