diff --git a/prisma/migrations/0121_v1121_partial_unique_live_rows/migration.sql b/prisma/migrations/0121_v1121_partial_unique_live_rows/migration.sql new file mode 100644 index 000000000..d53939bed --- /dev/null +++ b/prisma/migrations/0121_v1121_partial_unique_live_rows/migration.sql @@ -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; diff --git a/prisma/migrations/0122_v1121_mood_external_id/migration.sql b/prisma/migrations/0122_v1121_mood_external_id/migration.sql new file mode 100644 index 000000000..6dd638457 --- /dev/null +++ b/prisma/migrations/0122_v1121_mood_external_id/migration.sql @@ -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"); diff --git a/prisma/migrations/0123_v1121_perf_indexes/migration.sql b/prisma/migrations/0123_v1121_perf_indexes/migration.sql new file mode 100644 index 000000000..424a38a07 --- /dev/null +++ b/prisma/migrations/0123_v1121_perf_indexes/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0e928cd1..250120304 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e8ec780e5..5b55362ef 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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 diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index ac2dec883..c12e5daab 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -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: { diff --git a/src/lib/moodlog/sync.ts b/src/lib/moodlog/sync.ts index b7e447474..437b4ffe4 100644 --- a/src/lib/moodlog/sync.ts +++ b/src/lib/moodlog/sync.ts @@ -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}`); diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index 5887d0f3c..730a2eb0d 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -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(), diff --git a/tests/integration/partial-unique-live-rows.test.ts b/tests/integration/partial-unique-live-rows.test.ts new file mode 100644 index 000000000..c605a537c --- /dev/null +++ b/tests/integration/partial-unique-live-rows.test.ts @@ -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"); + }); +});