From 81096b95536f354b47d2bb7b4ccde0ffe6e96cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 23:08:12 +0200 Subject: [PATCH] feat(mood): add rated mood factors (ingestion + write contract) Extend the structured mood taxonomy so a tag can carry a magnitude. A rated factor is a MoodTag of kind RATED that the user scores per entry; the score persists on MoodEntryTagLink.rating. Binary tags keep kind BINARY and a null rating, so every existing row and read path stays byte-identical. Migration 0119 is additive: four marker columns on mood_tags (kind, scale_min, scale_max, inverse), a nullable rating on mood_entry_tag_links, and a seeded factors category with five MVP factors (work, social, sleep quality, stress, conflict). Stress and conflict are inverse-scaled (higher means worse); conflict is a 1..2 Yes/No factor. Write contract: POST /api/mood-entries and the bulk route accept an optional ratedFactors: [{ key, rating }] array parallel to the binary tagKeys. The resolver builds the join rows field-by-field, drops unknown and non-RATED keys silently, and rejects a rating outside the factor's own scaleMin..scaleMax (422 on the single-entry path, per-entry skipped on bulk). Create and list responses surface the persisted factors split from binary keys so a client hydrates without a refetch. GET /api/mood/tags now emits kind, scaleMin, scaleMax, and inverse so a client can render factors versus tags. This is the ingestion half; analytics over rated factors follow later. Factor labels ship in all six locales. --- ...r-to-ios-mood-v2-rated-factors-contract.md | 191 ++++++++++++++++++ messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- .../migration.sql | 77 +++++++ prisma/schema.prisma | 21 ++ .../api/mood-entries/__tests__/route.test.ts | 107 +++++++++- .../mood-entries/bulk/__tests__/route.test.ts | 82 +++++++- src/app/api/mood-entries/bulk/route.ts | 31 ++- src/app/api/mood-entries/route.ts | 88 ++++++-- src/app/api/mood/tags/__tests__/route.test.ts | 114 +++++++++++ src/app/api/mood/tags/route.ts | 14 +- src/components/mood/mood-tag-icons.ts | 4 + src/lib/mood/__tests__/tag-links.test.ts | 110 ++++++++++ src/lib/mood/tag-links.ts | 149 +++++++++++++- src/lib/validations/moodlog.ts | 25 +++ tests/integration/mood-entries-bulk.test.ts | 70 +++++++ 20 files changed, 1100 insertions(+), 43 deletions(-) create mode 100644 .planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md create mode 100644 prisma/migrations/0119_v1120_mood_rated_factors/migration.sql create mode 100644 src/app/api/mood/tags/__tests__/route.test.ts create mode 100644 src/lib/mood/__tests__/tag-links.test.ts diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md b/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md new file mode 100644 index 000000000..edd1db5ae --- /dev/null +++ b/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md @@ -0,0 +1,191 @@ +# server → iOS — v1.12.0 — Mood v2 rated factors (ingestion contract) + +**From:** server. **Date:** 2026-06-04. **Re:** your v0.14.1 item 3 — "send the +ingestion contract NOW so iOS can build the rating sliders." + +Landing in **v1.12.0**. This is the **ingestion half**: the write contract + +catalog metadata. You can build the rating UI against it today. The analytics +side (F4 rated-factor → mood correlation, better-days board) follows in a later +wave — it does NOT change anything you send. Nothing here is breaking: every +field is additive and optional; an iOS build that ignores it behaves exactly as +v1.11.x. + +Migration **0119** (additive: 5 columns + a seeded `factors` category with 5 +factors). No auth / route / scope change — same Bearer path as every mood write. + +--- + +## 1. Catalog metadata — `GET /api/mood/tags` + +Each tag in every category now carries four extra fields: + +```jsonc +{ + "categories": [ + { + "key": "factors", + "labelKey": "mood.tagCategory.factors", + "icon": "SlidersHorizontal", + "tags": [ + { + "key": "factor_work", + "labelKey": "mood.tag.factorWork", + "icon": "Briefcase", + "kind": "RATED", // NEW — "BINARY" (default) | "RATED" + "scaleMin": 1, // NEW — inclusive lower bound (RATED only) + "scaleMax": 5, // NEW — inclusive upper bound (RATED only) + "inverse": false // NEW — true = higher score means a WORSE day + } + ] + } + ] +} +``` + +- **`kind`** — `"BINARY"` is a present/absent flag (every tag you already + render). `"RATED"` is a *factor* the user scores per entry. Branch your + rendering on this: `BINARY` → today's toggle chip; `RATED` → a segmented + control from `scaleMin` to `scaleMax` (render a **Yes/No toggle** when + `scaleMax == 2`). +- **`scaleMin` / `scaleMax`** — the factor's own scale. Most are `1..5`; + `factor_conflict` is `1..2`. Pre-validate the slider against these; the + server also enforces them (see §3). +- **`inverse`** — `true` for factors where a higher score is worse (stress, + conflict). Capture stays literal (a high stress score is still a "5"); add a + directional hint in the UI ("Stress — higher = worse") so the user reads it + right. The (later) analytics layer flips the sign internally; you do nothing. +- Every existing `BINARY` tag now reports `kind:"BINARY"`, `scaleMin:1`, + `scaleMax:5`, `inverse:false` — ignore those fields for binary tags. + +The `labelKey` / `icon` resolve client-side exactly as today (the server stays +icon-library-agnostic — `icon` is a Lucide name). + +--- + +## 2. The 5 MVP rated factors (seeded global catalog) + +| key | labelKey | icon | scale | inverse | +|------------------------|-------------------------------|---------------------|-------|---------| +| `factor_work` | `mood.tag.factorWork` | `Briefcase` | 1..5 | false | +| `factor_social` | `mood.tag.factorSocial` | `Users` | 1..5 | false | +| `factor_sleep_quality` | `mood.tag.factorSleepQuality` | `Moon` | 1..5 | false | +| `factor_stress` | `mood.tag.factorStress` | `Zap` | 1..5 | **true**| +| `factor_conflict` | `mood.tag.factorConflict` | `Swords` | 1..2 | **true**| + +All sit in the new `factors` category (`labelKey: mood.tagCategory.factors`, +icon `SlidersHorizontal`). Labels ship in all 6 locales (de/en/es/fr/it/pl). +`factor_conflict` is a Yes/No factor (1 = no conflict, 2 = conflict) — render +it as a two-state toggle, not a 1..5 slider. + +These five match your ask (work / social / sleep / conflict). We followed the +server-side design doc's MVP set, which uses **stress** (inverse) as the fifth +rather than family — stress is the stronger correlation signal and family is +already a binary tag (`family`). If you specifically want a rated `factor_family` +too, say so and we'll seed it (trivial additive follow-up). + +--- + +## 3. How to send a rated factor + +### `POST /api/mood-entries` (single entry) + +Add a `ratedFactors` array, parallel to the existing `tagKeys` (binary). Both +are optional; send either, both, or neither. + +```jsonc +POST /api/mood-entries +{ + "mood": "GUT", + "moodLoggedAt": "2026-06-04T18:30:00+02:00", + "tags": ["coffee"], // unchanged flat free-text + "tagKeys": ["happy", "worked_out"], // unchanged binary structured tags + "ratedFactors": [ // NEW + { "key": "factor_work", "rating": 4 }, + { "key": "factor_social", "rating": 5 }, + { "key": "factor_stress", "rating": 2 }, + { "key": "factor_conflict", "rating": 1 } // 1 = no conflict (scale 1..2) + ] +} +``` + +- Each element is `{ "key": string, "rating": int }`. +- **Value range:** `rating` must be an integer within the factor's own + `scaleMin..scaleMax` (from the catalog). The outer envelope the server's Zod + accepts is `1..5`; a value outside the *factor's* scale (e.g. `rating:5` on + `factor_conflict` whose max is 2) is rejected — see error handling below. +- **Array bound:** up to 30 rated factors per entry. +- **Unknown / non-RATED keys are dropped silently** (same posture as + `tagKeys`) — only a key that resolves to a `kind:"RATED"` catalog tag lands. + So passing a binary key in `ratedFactors`, or a `factor_*` key in `tagKeys`, + is a no-op for that key. +- A factor the user didn't touch should simply be **omitted** — there is no + "0 / null" rating. No link row is written. + +**Response** (201) echoes the persisted set so you can hydrate without a +refetch — binary keys and rated factors are split by kind: + +```jsonc +{ + "data": { + "id": "…", "mood": "GUT", "date": "2026-06-04", "tags": ["coffee"], + "tagKeys": ["happy", "worked_out"], + "ratedFactors": [ { "key": "factor_work", "rating": 4 }, … ] + } +} +``` + +The list `GET /api/mood-entries` carries the same `ratedFactors` array per +entry — use it to pre-fill the edit form's sliders. + +### Out-of-scale rating → 422 + +If a rating falls outside the factor's `scaleMin..scaleMax`, the single-entry +POST returns: + +```jsonc +HTTP 422 +{ "data": null, "error": "Rating 5 for factor \"factor_conflict\" is outside its scale 1..2", + "meta": { "errorCode": "mood.ratedFactor.out_of_range" } } +``` + +Branch on `meta.errorCode == "mood.ratedFactor.out_of_range"`. Best avoided by +clamping the slider to the catalog scale client-side. + +### `POST /api/mood-entries/bulk` (adopt-on-pair backfill) + +Same `ratedFactors: [{ key, rating }]` field per entry in the `entries[]` +array (same bounds, same drop-unknown posture): + +```jsonc +POST /api/mood-entries/bulk +{ + "entries": [ + { + "mood": "GUT", + "moodLoggedAt": "2026-05-16T08:00:00.000Z", + "tagKeys": ["movies"], + "ratedFactors": [ { "key": "factor_work", "rating": 4 } ] + } + ] +} +``` + +**Per-entry isolation:** an out-of-scale rating marks **that one entry** +`"skipped"` in the per-entry `entries[]` status array — it never fails the +whole batch. The mood row itself still upserts; only the bad rated link is +dropped. So clamp client-side to keep entries from being skipped. + +--- + +## 4. Notes + +- The `moodLog` webhook stays free-text-tags only (as before); rated factors + require the authenticated `POST /api/mood-entries` / `/bulk` path. +- These five `factor_*` keys + the 4 new catalog fields + the `ratedFactors` + write field are the **complete** contract. Nothing else changed. +- The mood-entries write routes are not in `docs/api/openapi.yaml` (they never + were), so there's no codegen surface for this — work from this note. + +**Ask back:** confirm the 5-factor set works for your slider UI, and whether +you want a rated `factor_family` seeded alongside (vs the existing binary +`family` tag). Ping per item. Thanks — server. diff --git a/messages/de.json b/messages/de.json index 209bca68d..a9eeb97ea 100644 --- a/messages/de.json +++ b/messages/de.json @@ -511,7 +511,8 @@ "health": "Gesundheit", "social": "Soziales", "work": "Arbeit", - "hobbies": "Hobbys" + "hobbies": "Hobbys", + "factors": "Faktoren" }, "tag": { "happy": "glücklich", @@ -551,7 +552,12 @@ "outdoors": "draußen", "fastFood": "Fast Food", "noSweets": "keine Süßigkeiten", - "bigMeal": "üppige Mahlzeit" + "bigMeal": "üppige Mahlzeit", + "factorWork": "Arbeit", + "factorSocial": "Soziales", + "factorSleepQuality": "Schlafqualität", + "factorStress": "Stress", + "factorConflict": "Konflikt" } }, "medications": { diff --git a/messages/en.json b/messages/en.json index f9a9b2714..a3bc9c9d4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -511,7 +511,8 @@ "health": "Health", "social": "Social", "work": "Work", - "hobbies": "Hobbies" + "hobbies": "Hobbies", + "factors": "Factors" }, "tag": { "happy": "happy", @@ -551,7 +552,12 @@ "outdoors": "outdoors", "fastFood": "fast food", "noSweets": "no sweets", - "bigMeal": "big meal" + "bigMeal": "big meal", + "factorWork": "work", + "factorSocial": "social", + "factorSleepQuality": "sleep quality", + "factorStress": "stress", + "factorConflict": "conflict" } }, "medications": { diff --git a/messages/es.json b/messages/es.json index 3791708a4..2d7378e61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -511,7 +511,8 @@ "health": "Salud", "social": "Social", "work": "Trabajo", - "hobbies": "Aficiones" + "hobbies": "Aficiones", + "factors": "Factores" }, "tag": { "happy": "feliz", @@ -551,7 +552,12 @@ "outdoors": "aire libre", "fastFood": "comida rápida", "noSweets": "sin dulces", - "bigMeal": "comilona" + "bigMeal": "comilona", + "factorWork": "trabajo", + "factorSocial": "vida social", + "factorSleepQuality": "calidad del sueño", + "factorStress": "estrés", + "factorConflict": "conflicto" } }, "medications": { diff --git a/messages/fr.json b/messages/fr.json index 89c1a58f4..8d2bf3284 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -511,7 +511,8 @@ "health": "Santé", "social": "Social", "work": "Travail", - "hobbies": "Loisirs" + "hobbies": "Loisirs", + "factors": "Facteurs" }, "tag": { "happy": "heureux", @@ -551,7 +552,12 @@ "outdoors": "plein air", "fastFood": "fast-food", "noSweets": "sans sucreries", - "bigMeal": "gros repas" + "bigMeal": "gros repas", + "factorWork": "travail", + "factorSocial": "vie sociale", + "factorSleepQuality": "qualité du sommeil", + "factorStress": "stress", + "factorConflict": "conflit" } }, "medications": { diff --git a/messages/it.json b/messages/it.json index a97e68dd6..51878b0d4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -511,7 +511,8 @@ "health": "Salute", "social": "Sociale", "work": "Lavoro", - "hobbies": "Hobby" + "hobbies": "Hobby", + "factors": "Fattori" }, "tag": { "happy": "felice", @@ -551,7 +552,12 @@ "outdoors": "all'aperto", "fastFood": "fast food", "noSweets": "niente dolci", - "bigMeal": "pasto abbondante" + "bigMeal": "pasto abbondante", + "factorWork": "lavoro", + "factorSocial": "vita sociale", + "factorSleepQuality": "qualità del sonno", + "factorStress": "stress", + "factorConflict": "conflitto" } }, "medications": { diff --git a/messages/pl.json b/messages/pl.json index ac66f6cba..77956ae38 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -511,7 +511,8 @@ "health": "Zdrowie", "social": "Społeczne", "work": "Praca", - "hobbies": "Hobby" + "hobbies": "Hobby", + "factors": "Czynniki" }, "tag": { "happy": "szczęśliwy", @@ -551,7 +552,12 @@ "outdoors": "na dworze", "fastFood": "fast food", "noSweets": "bez słodyczy", - "bigMeal": "obfity posiłek" + "bigMeal": "obfity posiłek", + "factorWork": "praca", + "factorSocial": "życie towarzyskie", + "factorSleepQuality": "jakość snu", + "factorStress": "stres", + "factorConflict": "konflikt" } }, "medications": { diff --git a/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql b/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql new file mode 100644 index 000000000..7ceac1cd5 --- /dev/null +++ b/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql @@ -0,0 +1,77 @@ +-- v1.12.0 — rated mood factors (ingestion half). +-- +-- Additive on top of the binary mood-tag taxonomy (0101 / 0118). A rated +-- factor is a `MoodTag` whose `kind = 'RATED'`: the user scores it 1..5 +-- (or the tag's own scale) per entry, and the score rides on the existing +-- `mood_entry_tag_links` join via a new `rating` column. Binary tags keep +-- `kind = 'BINARY'` and `rating = NULL`, so every pre-existing row and +-- read path stays byte-identical. +-- +-- Two `ALTER TABLE ADD COLUMN` blocks (all NOT NULL with a default, or +-- nullable) keep this strictly additive — no backfill, no rewrite. Then +-- seed the five MVP rated factors under a new `factors` category. The +-- inserts use deterministic ids + ON CONFLICT upsert by `key` so a re-run +-- is idempotent, mirroring 0101 / 0118 exactly. The seed only touches the +-- new `factor_*` rows + `factors` category — the binary catalog is left +-- alone. + +-- ─── Discriminator + scale columns on the catalog tag ──────────────── +ALTER TABLE "mood_tags" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'BINARY'; +ALTER TABLE "mood_tags" ADD COLUMN "scale_min" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "mood_tags" ADD COLUMN "scale_max" INTEGER NOT NULL DEFAULT 5; +ALTER TABLE "mood_tags" ADD COLUMN "inverse" BOOLEAN NOT NULL DEFAULT false; + +-- ─── Per-entry rating on the join ──────────────────────────────────── +ALTER TABLE "mood_entry_tag_links" ADD COLUMN "rating" INTEGER; + +-- ─── New category: factors ─────────────────────────────────────────── +INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") +VALUES ('mtc_factors', 'factors', 'mood.tagCategory.factors', 'SlidersHorizontal', 6, true) +ON CONFLICT ("key") DO UPDATE SET + "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order"; + +-- ─── The five MVP rated factors ────────────────────────────────────── +-- work (1..5) +INSERT INTO "mood_tags" + ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse") +VALUES ('mt_factor_work', 'mtc_factors', 'factor_work', 'mood.tag.factorWork', 'Briefcase', 0, true, 'RATED', 1, 5, false) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse"; + +-- social (1..5) +INSERT INTO "mood_tags" + ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse") +VALUES ('mt_factor_social', 'mtc_factors', 'factor_social', 'mood.tag.factorSocial', 'Users', 1, true, 'RATED', 1, 5, false) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse"; + +-- sleep quality (1..5) +INSERT INTO "mood_tags" + ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse") +VALUES ('mt_factor_sleep_quality', 'mtc_factors', 'factor_sleep_quality', 'mood.tag.factorSleepQuality', 'Moon', 2, true, 'RATED', 1, 5, false) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse"; + +-- stress (1..5, inverse — higher = worse) +INSERT INTO "mood_tags" + ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse") +VALUES ('mt_factor_stress', 'mtc_factors', 'factor_stress', 'mood.tag.factorStress', 'Zap', 3, true, 'RATED', 1, 5, true) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse"; + +-- conflict (1..2 Yes/No, inverse — 1 = no conflict, 2 = conflict) +INSERT INTO "mood_tags" + ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse") +VALUES ('mt_factor_conflict', 'mtc_factors', 'factor_conflict', 'mood.tag.factorConflict', 'Swords', 4, true, 'RATED', 1, 2, true) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8880649d1..b0e928cd1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1993,6 +1993,22 @@ model MoodTag { sortOrder Int @default(0) @map("sort_order") isActive Boolean @default(true) @map("is_active") + /// v1.12.0 — capture-kind discriminator. `BINARY` (default) is a + /// present/absent flag — every tag seeded by 0101 / 0118. `RATED` is a + /// mood *factor* the user scores per entry (e.g. work, social, sleep + /// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one + /// taxonomy + one ingestion path for both flavours. + kind String @default("BINARY") @map("kind") // "BINARY" | "RATED" + /// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale + /// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags. + /// The server validates each submitted rating against this range. + scaleMin Int @default(1) @map("scale_min") + scaleMax Int @default(5) @map("scale_max") + /// v1.12.0 — inverse-scaled factor (e.g. stress / conflict) where a + /// HIGHER rating means a WORSE day. Capture stays literal; the (later) + /// analytics layer flips the sign so "up" always reads as "better". + inverse Boolean @default(false) @map("inverse") + category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) links MoodEntryTagLink[] @@ -2008,6 +2024,11 @@ model MoodEntryTagLink { moodEntryId String @map("mood_entry_id") moodTagId String @map("mood_tag_id") createdAt DateTime @default(now()) @map("created_at") + /// v1.12.0 — per-entry score for a RATED `MoodTag`. NULL for BINARY + /// links (the present/absent flag carries the signal). Bounded to the + /// tag's `scaleMin..scaleMax` at ingest (server-validated, not a DB + /// CHECK, so a future scale change never strands historical rows). + rating Int? @map("rating") moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade) moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade) diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index d15b8852c..69b666407 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -34,9 +34,17 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/mood/tag-links", () => ({ - createTagLinks: vi.fn().mockResolvedValue(undefined), -})); +// v1.12.0 — keep the real `RatedFactorOutOfRangeError` so the route's +// `instanceof` 422 branch still matches when the mock throws it. +vi.mock("@/lib/mood/tag-links", async () => { + const actual = await vi.importActual( + "@/lib/mood/tag-links", + ); + return { + createTagLinks: vi.fn().mockResolvedValue(undefined), + RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError, + }; +}); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -239,8 +247,14 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () const res = await POST(postReq(VALID_BODY)); expect(res.status).toBe(201); // The tag-link write runs against the same `$transaction` client that - // created the entry, so both commit (or roll back) together. - expect(createTagLinks).toHaveBeenCalledWith("mood-1", ["happy"], txClient); + // created the entry, so both commit (or roll back) together. The 4th + // arg is the rated-factor set (empty here — binary tag only). + expect(createTagLinks).toHaveBeenCalledWith( + "mood-1", + ["happy"], + txClient, + [], + ); }); it("returns the persisted tagKeys in the create response", async () => { @@ -293,3 +307,86 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled(); }); }); + +describe("POST /api/mood-entries — rated factors (v1.12.0)", () => { + const ENTRY = { + id: "mood-rf", + tags: null, + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + mood: "GUT", + note: null, + source: "MANUAL", + date: "2026-06-01", + }; + + it("threads ratedFactors into createTagLinks", async () => { + txClient.moodEntry.create.mockResolvedValue(ENTRY); + const body = { + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + tagKeys: ["happy"], + ratedFactors: [{ key: "factor_work", rating: 4 }], + }; + const res = await POST(postReq(body)); + expect(res.status).toBe(201); + expect(createTagLinks).toHaveBeenCalledWith( + "mood-rf", + ["happy"], + txClient, + [{ key: "factor_work", rating: 4 }], + ); + }); + + it("surfaces persisted rated factors split from binary tagKeys in the response", async () => { + txClient.moodEntry.create.mockResolvedValue(ENTRY); + // Read-back returns one binary link + one rated link; the route splits + // them by `kind`. + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { rating: null, moodTag: { key: "happy", kind: "BINARY" } }, + { rating: 4, moodTag: { key: "factor_work", kind: "RATED" } }, + ]); + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + tagKeys: ["happy"], + ratedFactors: [{ key: "factor_work", rating: 4 }], + }), + ); + expect(res.status).toBe(201); + const out = (await res.json()) as { + data: { tagKeys: string[]; ratedFactors: { key: string; rating: number }[] }; + }; + expect(out.data.tagKeys).toEqual(["happy"]); + expect(out.data.ratedFactors).toEqual([{ key: "factor_work", rating: 4 }]); + }); + + it("returns 422 with errorCode when a rating is out of the factor's scale", async () => { + txClient.moodEntry.create.mockResolvedValue(ENTRY); + const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links"); + vi.mocked(createTagLinks).mockRejectedValueOnce( + new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2), + ); + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + ratedFactors: [{ key: "factor_conflict", rating: 5 }], + }), + ); + expect(res.status).toBe(422); + const out = (await res.json()) as { meta?: { errorCode?: string } }; + expect(out.meta?.errorCode).toBe("mood.ratedFactor.out_of_range"); + }); + + it("rejects a non-integer / out-of-envelope rating at the Zod layer (422)", async () => { + const res = await POST( + postReq({ + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + ratedFactors: [{ key: "factor_work", rating: 9 }], + }), + ); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 1e255d225..28467f62b 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -15,9 +15,17 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/mood/tag-links", () => ({ - createTagLinks: vi.fn().mockResolvedValue(undefined), -})); +// v1.12.0 — keep the real `RatedFactorOutOfRangeError` so the per-entry +// catch can `instanceof`-match a thrown out-of-scale rating. +vi.mock("@/lib/mood/tag-links", async () => { + const actual = await vi.importActual( + "@/lib/mood/tag-links", + ); + return { + createTagLinks: vi.fn().mockResolvedValue(undefined), + RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError, + }; +}); vi.mock("@/lib/moodlog/push", () => ({ pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), @@ -195,7 +203,14 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); expect(res.status).toBe(200); expect(createTagLinks).toHaveBeenCalledTimes(1); - expect(createTagLinks).toHaveBeenCalledWith("entry-1", ["movies", "gaming"]); + // v1.12.0 — the bulk path now also threads the prisma client (3rd arg, + // outside the upsert tx) and the rated-factor set (4th, empty here). + expect(createTagLinks).toHaveBeenCalledWith( + "entry-1", + ["movies", "gaming"], + prisma, + [], + ); }); it("skips the tag-link write when an entry sends no tagKeys", async () => { @@ -223,4 +238,63 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { expect(res.status).toBe(422); expect(createTagLinks).not.toHaveBeenCalled(); }); + + it("threads ratedFactors into createTagLinks for a bulk entry", async () => { + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: ["movies"], + ratedFactors: [{ key: "factor_work", rating: 4 }], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { inserted: number } }; + expect(json.data.inserted).toBe(1); + expect(createTagLinks).toHaveBeenCalledWith( + "entry-1", + ["movies"], + prisma, + [{ key: "factor_work", rating: 4 }], + ); + }); + + it("marks the single entry skipped (not the batch) on an out-of-scale rating", async () => { + const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links"); + // First entry's factor write throws; the loop catch records it skipped. + vi.mocked(createTagLinks).mockRejectedValueOnce( + new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2), + ); + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + ratedFactors: [{ key: "factor_conflict", rating: 5 }], + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + processed: number; + inserted: number; + entries: Array<{ index: number; status: string }>; + }; + }; + expect(json.data.processed).toBe(2); + // Entry 0 skipped (bad factor), entry 1 inserted clean. + expect(json.data.entries[0].status).toBe("skipped"); + expect(json.data.entries[1].status).toBe("inserted"); + }); }); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index f5fd6ad95..3c26b8cdb 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -68,6 +68,20 @@ const bulkEntrySchema = z.object({ * link set. */ tagKeys: z.array(z.string().max(60)).max(30).optional(), + /** + * v1.12.0 — rated mood factors (`kind = 'RATED'` catalog tags carrying + * a per-entry score). Parallel to the binary `tagKeys`; persisted on + * `MoodEntryTagLink.rating`. The outer 1..5 here is the envelope; the + * server rejects a rating outside the resolved factor's own + * `scaleMin..scaleMax` (e.g. 1..2 for `factor_conflict`) — on the bulk + * path that marks the single entry `skipped`, never the whole batch. + */ + ratedFactors: z + .array( + z.object({ key: z.string().max(60), rating: z.number().int().min(1).max(5) }), + ) + .max(30) + .optional(), note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), @@ -224,8 +238,21 @@ async function postBulk(request: NextRequest): Promise { // re-posted entry from minting duplicate links. Runs for both // fresh and re-posted (upserted) rows so a backfill that adds tag // keys on a second pass still lands them. - if (entry.tagKeys && entry.tagKeys.length > 0) { - await createTagLinks(result.id, entry.tagKeys); + // v1.12.0 — rated factors ride the same path; an out-of-scale + // rating throws `RatedFactorOutOfRangeError`, which the per-entry + // catch below turns into a `skipped` result (the rest of the batch + // still lands). The mood row itself already upserted, so a skipped + // factor leaves a valid entry with no rated links. + if ( + (entry.tagKeys && entry.tagKeys.length > 0) || + (entry.ratedFactors && entry.ratedFactors.length > 0) + ) { + await createTagLinks( + result.id, + entry.tagKeys ?? [], + prisma, + entry.ratedFactors ?? [], + ); } if (existing) { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 40a95fe20..42a1aba11 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -22,7 +22,10 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; -import { createTagLinks } from "@/lib/mood/tag-links"; +import { + createTagLinks, + RatedFactorOutOfRangeError, +} from "@/lib/mood/tag-links"; function parseTags(tags: string | null): string[] { if (!tags) return []; @@ -93,9 +96,14 @@ export const GET = apiHandler(async (request: NextRequest) => { skip: offset, // v1.8.5 — include the structured-tag link keys so the edit form // can pre-populate the taxonomy picker. + // v1.12.0 — also carry the per-link `rating` + the tag `kind` so + // the client can split binary tags from rated factors on hydrate. include: { tagLinks: { - select: { moodTag: { select: { key: true } } }, + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, }, }, }), @@ -110,8 +118,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ ...e, tags: parseTags(e.tags), - // v1.8.5 — flat list of structured-tag keys attached to the entry. - tagKeys: tagLinks.map((link) => link.moodTag.key), + // v1.8.5 — flat list of binary structured-tag keys attached to the + // entry (rated factors are surfaced separately below). + tagKeys: tagLinks + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + // v1.12.0 — rated factors with their per-entry score, so the edit + // form re-renders the sliders without a refetch. + ratedFactors: tagLinks + .filter((link) => link.moodTag.kind === "RATED" && link.rating !== null) + .map((link) => ({ key: link.moodTag.key, rating: link.rating as number })), })); return apiSuccess({ @@ -157,7 +173,8 @@ async function postMoodEntry(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { mood, tags, tagKeys, note, moodLoggedAt, source } = parsed.data; + const { mood, tags, tagKeys, ratedFactors, note, moodLoggedAt, source } = + parsed.data; // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's // current displayTimezone and store the resolved zone on the row. // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin @@ -172,8 +189,8 @@ async function postMoodEntry(request: NextRequest) { // tag-link failure must roll the entry back too — otherwise a client // retry on the 5xx mints a duplicate entry. The tx client is threaded // through the helper so both writes commit (or abort) together. - const { entry, persistedTagKeys } = await prisma.$transaction( - async (tx) => { + const { entry, persistedTagKeys, persistedRatedFactors } = + await prisma.$transaction(async (tx) => { const created = await tx.moodEntry.create({ data: { userId: user.id, @@ -188,26 +205,47 @@ async function postMoodEntry(request: NextRequest) { }, }); - if (tagKeys && tagKeys.length > 0) { - // Unknown keys are dropped inside the helper (the catalog is the - // source of truth). - await createTagLinks(created.id, tagKeys, tx); + if ( + (tagKeys && tagKeys.length > 0) || + (ratedFactors && ratedFactors.length > 0) + ) { + // Unknown / non-RATED keys are dropped inside the helper (the + // catalog is the source of truth). An out-of-scale rating + // throws `RatedFactorOutOfRangeError`, rolling the tx back. + await createTagLinks( + created.id, + tagKeys ?? [], + tx, + ratedFactors ?? [], + ); } - // v1.8.5 — read the persisted link keys back so the create - // response mirrors the list GET shape exactly (unknown keys - // already filtered out). + // v1.8.5 / v1.12.0 — read the persisted links back so the create + // response mirrors the list GET shape exactly: binary keys and + // rated factors split by `kind` (unknown keys already filtered). const links = await tx.moodEntryTagLink.findMany({ where: { moodEntryId: created.id }, - select: { moodTag: { select: { key: true } } }, + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, }); return { entry: created, - persistedTagKeys: links.map((link) => link.moodTag.key), + persistedTagKeys: links + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + persistedRatedFactors: links + .filter( + (link) => link.moodTag.kind === "RATED" && link.rating !== null, + ) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), }; - }, - ); + }); await auditLog("moodEntry.create", { userId: user.id, @@ -271,10 +309,24 @@ async function postMoodEntry(request: NextRequest) { // hydrating from the create response renders the tag set without a // refetch (shape-matches the list GET). tagKeys: persistedTagKeys, + // v1.12.0 — rated factors with their per-entry score. + ratedFactors: persistedRatedFactors, }, 201, ); } catch (err) { + // v1.12.0 — a factor rating outside its catalog scale is a client + // error, not a 5xx. The Zod schema only enforces the 1..5 envelope; + // the per-tag scale (e.g. 1..2 for conflict) is the real gate. + if (err instanceof RatedFactorOutOfRangeError) { + annotate({ + action: { name: "mood-entries.create.rated-factor-out-of-range" }, + meta: { scaleMin: err.scaleMin, scaleMax: err.scaleMax }, + }); + return apiError(err.message, 422, { + errorCode: "mood.ratedFactor.out_of_range", + }); + } if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts new file mode 100644 index 000000000..a890bca48 --- /dev/null +++ b/src/app/api/mood/tags/__tests__/route.test.ts @@ -0,0 +1,114 @@ +/** + * v1.12.0 — `GET /api/mood/tags` must expose the rated-factor metadata + * (`kind` / `scaleMin` / `scaleMax` / `inverse`) so the client can branch + * between binary toggle chips and rated segmented controls. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const categoryFindMany = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/mood/tags — rated-factor catalog metadata (v1.12.0)", () => { + it("selects + emits kind / scaleMin / scaleMax / inverse per tag", async () => { + categoryFindMany.mockResolvedValue([ + { + key: "factors", + labelKey: "mood.tagCategory.factors", + icon: "SlidersHorizontal", + tags: [ + { + key: "factor_work", + labelKey: "mood.tag.factorWork", + icon: "Briefcase", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + inverse: false, + }, + { + key: "factor_conflict", + labelKey: "mood.tag.factorConflict", + icon: "Swords", + kind: "RATED", + scaleMin: 1, + scaleMax: 2, + inverse: true, + }, + ], + }, + ]); + + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + categories: Array<{ + key: string; + tags: Array<{ + key: string; + kind: string; + scaleMin: number; + scaleMax: number; + inverse: boolean; + }>; + }>; + }; + }; + + // The Prisma select must request the new columns. + const selectArg = categoryFindMany.mock.calls[0]?.[0] as { + select: { tags: { select: Record } }; + }; + expect(selectArg.select.tags.select).toMatchObject({ + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + }); + + const factor = body.data.categories[0].tags[0]; + expect(factor.kind).toBe("RATED"); + expect(factor.scaleMin).toBe(1); + expect(factor.scaleMax).toBe(5); + expect(factor.inverse).toBe(false); + + const conflict = body.data.categories[0].tags[1]; + expect(conflict.scaleMax).toBe(2); + expect(conflict.inverse).toBe(true); + }); +}); diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts index 6cf10e667..ba13023ba 100644 --- a/src/app/api/mood/tags/route.ts +++ b/src/app/api/mood/tags/route.ts @@ -29,7 +29,19 @@ export const GET = apiHandler(async () => { tags: { where: { isActive: true }, orderBy: { sortOrder: "asc" }, - select: { key: true, labelKey: true, icon: true }, + // v1.12.0 — emit the rated-factor metadata so the client can + // branch rendering: `kind = 'BINARY'` → a toggle chip; `'RATED'` + // → a 1..scaleMax segmented control (Yes/No when scaleMax = 2). + // `inverse` flags factors where a higher score means a worse day. + select: { + key: true, + labelKey: true, + icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + }, }, }, }); diff --git a/src/components/mood/mood-tag-icons.ts b/src/components/mood/mood-tag-icons.ts index aabb3884e..41141a5bf 100644 --- a/src/components/mood/mood-tag-icons.ts +++ b/src/components/mood/mood-tag-icons.ts @@ -31,7 +31,9 @@ import { PartyPopper, Pizza, Plane, + SlidersHorizontal, Smile, + Swords, Tag, Thermometer, ThumbsUp, @@ -84,7 +86,9 @@ const MOOD_TAG_ICONS: Record = { PartyPopper, Pizza, Plane, + SlidersHorizontal, Smile, + Swords, Thermometer, ThumbsUp, Trees, diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts new file mode 100644 index 000000000..f171a7091 --- /dev/null +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -0,0 +1,110 @@ +/** + * v1.12.0 — rated mood factors: resolver + write-path unit coverage. + * + * `resolveRatedFactors` resolves catalog keys, drops unknown / non-RATED + * keys, and rejects an out-of-scale rating. `createTagLinks` persists the + * rating onto the join and de-dupes a key passed both as a binary key and + * a rated factor. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const moodTagFindMany = vi.fn(); +const linkCreateMany = vi.fn().mockResolvedValue({ count: 0 }); + +vi.mock("@/lib/db", () => ({ + prisma: { + moodTag: { findMany: (...a: unknown[]) => moodTagFindMany(...a) }, + moodEntryTagLink: { createMany: (...a: unknown[]) => linkCreateMany(...a) }, + }, +})); + +import { + resolveRatedFactors, + createTagLinks, + RatedFactorOutOfRangeError, +} from "@/lib/mood/tag-links"; + +beforeEach(() => { + moodTagFindMany.mockReset(); + linkCreateMany.mockReset().mockResolvedValue({ count: 0 }); +}); + +describe("resolveRatedFactors", () => { + it("resolves a valid factor to its catalog id + rating", async () => { + moodTagFindMany.mockResolvedValue([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]); + const out = await resolveRatedFactors([{ key: "factor_work", rating: 4 }]); + expect(out).toEqual([{ moodTagId: "mt_factor_work", rating: 4 }]); + }); + + it("drops unknown / non-RATED keys (catalog is the source of truth)", async () => { + // The query already filters by `kind: 'RATED'` + `isActive`, so an + // unknown / binary key simply doesn't come back. + moodTagFindMany.mockResolvedValue([]); + const out = await resolveRatedFactors([{ key: "happy", rating: 3 }]); + expect(out).toEqual([]); + }); + + it("rejects a rating above the factor's scaleMax", async () => { + moodTagFindMany.mockResolvedValue([ + { + id: "mt_factor_conflict", + key: "factor_conflict", + scaleMin: 1, + scaleMax: 2, + }, + ]); + await expect( + resolveRatedFactors([{ key: "factor_conflict", rating: 4 }]), + ).rejects.toBeInstanceOf(RatedFactorOutOfRangeError); + }); + + it("rejects a rating below the factor's scaleMin", async () => { + moodTagFindMany.mockResolvedValue([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]); + await expect( + resolveRatedFactors([{ key: "factor_work", rating: 0 }]), + ).rejects.toBeInstanceOf(RatedFactorOutOfRangeError); + }); + + it("is a no-op on an empty input", async () => { + const out = await resolveRatedFactors([]); + expect(out).toEqual([]); + expect(moodTagFindMany).not.toHaveBeenCalled(); + }); +}); + +describe("createTagLinks with rated factors", () => { + it("persists the rating onto the join row", async () => { + moodTagFindMany.mockResolvedValue([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]); + await createTagLinks("entry-1", [], undefined, [ + { key: "factor_work", rating: 5 }, + ]); + expect(linkCreateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: [{ moodEntryId: "entry-1", moodTagId: "mt_factor_work", rating: 5 }], + }), + ); + }); + + it("propagates the out-of-range error before any write", async () => { + moodTagFindMany.mockResolvedValue([ + { + id: "mt_factor_conflict", + key: "factor_conflict", + scaleMin: 1, + scaleMax: 2, + }, + ]); + await expect( + createTagLinks("entry-1", [], undefined, [ + { key: "factor_conflict", rating: 5 }, + ]), + ).rejects.toBeInstanceOf(RatedFactorOutOfRangeError); + expect(linkCreateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 7be96add7..857ca0216 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -20,6 +20,84 @@ import { prisma } from "@/lib/db"; */ type TagLinkDb = PrismaClient | Prisma.TransactionClient; +/** + * v1.12.0 — a rated factor as it arrives on the wire: a catalog + * (`mood_tags.key`) + the user's score for this entry. + */ +export interface RatedFactorInput { + key: string; + rating: number; +} + +/** + * v1.12.0 — a rated factor resolved to its catalog row id, ready to + * write onto the `mood_entry_tag_links` join. + */ +interface ResolvedRatedFactor { + moodTagId: string; + rating: number; +} + +/** + * v1.12.0 — thrown when a submitted factor rating falls outside the + * resolved `MoodTag`'s own `scaleMin..scaleMax`. The route maps this to a + * 422 (the per-tag scale is the real gate; the Zod schema only enforces + * the outer 1..5 envelope). Unknown / non-RATED keys are NOT an error — + * they are dropped silently, matching the binary `tagKeys` posture. + */ +export class RatedFactorOutOfRangeError extends Error { + constructor( + public readonly key: string, + public readonly rating: number, + public readonly scaleMin: number, + public readonly scaleMax: number, + ) { + super( + `Rating ${rating} for factor "${key}" is outside its scale ${scaleMin}..${scaleMax}`, + ); + this.name = "RatedFactorOutOfRangeError"; + } +} + +/** + * Resolve rated-factor inputs against the catalog. Unknown keys, inactive + * tags, and `kind = 'BINARY'` keys are dropped silently (the catalog is + * the source of truth, same posture as `resolveTagKeysToIds`). A rating + * outside the resolved tag's `scaleMin..scaleMax` throws + * `RatedFactorOutOfRangeError` so the route returns 422. The last value + * wins on a duplicate key in the same payload. + */ +export async function resolveRatedFactors( + factors: RatedFactorInput[], + db: TagLinkDb = prisma, +): Promise { + if (factors.length === 0) return []; + // Last-writer-wins on a duplicate key in the same submission. + const byKey = new Map(); + for (const f of factors) byKey.set(f.key, f.rating); + + const rows = await db.moodTag.findMany({ + where: { key: { in: [...byKey.keys()] }, isActive: true, kind: "RATED" }, + select: { id: true, key: true, scaleMin: true, scaleMax: true }, + }); + + const resolved: ResolvedRatedFactor[] = []; + for (const row of rows) { + const rating = byKey.get(row.key); + if (rating === undefined) continue; + if (rating < row.scaleMin || rating > row.scaleMax) { + throw new RatedFactorOutOfRangeError( + row.key, + rating, + row.scaleMin, + row.scaleMax, + ); + } + resolved.push({ moodTagId: row.id, rating }); + } + return resolved; +} + /** * Resolve catalog tag keys to ids, dropping unknown / inactive keys. * Returns the ids in catalog order (deduped). @@ -40,13 +118,44 @@ export async function resolveTagKeysToIds( /** * Create the structured-tag links for a freshly-created entry. No-op on * an empty / all-unknown key set. + * + * v1.12.0 — optionally also writes rated-factor links (`kind = 'RATED'` + * catalog tags carrying a per-entry `rating`). Binary keys leave `rating` + * NULL; rated factors persist their score. A factor key passed in both + * `keys` and `ratedFactors` resolves to a single link carrying the + * rating (the rated insert wins via `skipDuplicates`, which is why the + * rated rows are written first). Throws `RatedFactorOutOfRangeError` (→ + * route 422) when a rating is out of the factor's scale. */ export async function createTagLinks( moodEntryId: string, keys: string[], db: TagLinkDb = prisma, + ratedFactors: RatedFactorInput[] = [], ): Promise { - const tagIds = await resolveTagKeysToIds(keys, db); + // Resolve rated factors FIRST so an out-of-range rating aborts before + // any write, and so the rated rows (which carry a value) take priority + // over a bare binary row for the same tag id under `skipDuplicates`. + const resolvedFactors = await resolveRatedFactors(ratedFactors, db); + const ratedTagIds = new Set(resolvedFactors.map((f) => f.moodTagId)); + + if (resolvedFactors.length > 0) { + await db.moodEntryTagLink.createMany({ + // Field-by-field — no mass assignment of the wire object. + data: resolvedFactors.map((f) => ({ + moodEntryId, + moodTagId: f.moodTagId, + rating: f.rating, + })), + skipDuplicates: true, + }); + } + + const tagIds = (await resolveTagKeysToIds(keys, db)).filter( + // A key already written as a rated link is not re-inserted as a + // bare binary row (that would lose the rating to `skipDuplicates`). + (id) => !ratedTagIds.has(id), + ); if (tagIds.length === 0) return; await db.moodEntryTagLink.createMany({ data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })), @@ -87,3 +196,41 @@ export async function replaceTagLinks( }); } } + +/** + * v1.12.0 — replace the full rated-factor link set for an entry. A + * changed rating on the same factor is a real change (not a no-op), so + * the simplest correct shape is "delete every RATED link for the entry, + * re-insert the desired set". Binary links are left untouched. Passing an + * empty array clears every rated link. Throws `RatedFactorOutOfRangeError` + * (→ route 422) before any write when a rating is out of scale. + * + * Not wired into a route yet (the POST + bulk ingestion contract is the + * v1.12.0 scope); exported for the PATCH edit path a later wave adds. + */ +export async function replaceRatedFactorLinks( + moodEntryId: string, + factors: RatedFactorInput[], + db: TagLinkDb = prisma, +): Promise { + // Resolve (and range-check) before mutating so an out-of-range rating + // aborts the replace cleanly. + const resolved = await resolveRatedFactors(factors, db); + + // Drop every existing RATED link for the entry (a `rating IS NOT NULL` + // row is, by construction, a rated-factor link). + await db.moodEntryTagLink.deleteMany({ + where: { moodEntryId, rating: { not: null } }, + }); + + if (resolved.length > 0) { + await db.moodEntryTagLink.createMany({ + data: resolved.map((f) => ({ + moodEntryId, + moodTagId: f.moodTagId, + rating: f.rating, + })), + skipDuplicates: true, + }); + } +} diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index 54f9dbd81..beb4ddb63 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -83,6 +83,23 @@ export function getScoreForMood(mood: string): number { // Bounded so a single create can't fan out an unbounded link set. const structuredTagKeys = z.array(z.string().max(60)).max(30); +// v1.12.0 — rated mood factors. A factor is a catalog `MoodTag` of +// `kind = 'RATED'`; the user scores it per entry, and the score persists +// on `MoodEntryTagLink.rating`. The wire shape is a parallel array to +// `tagKeys` (binary), keeping the binary contract byte-identical and the +// iOS Codable model simple (`[{ key, rating }]`). +// +// The Zod `rating` bound here is the OUTER envelope (1..5 covers every +// seeded factor's scale). The REAL gate is per-tag: after resolving each +// key to its `MoodTag`, the server rejects a rating outside the tag's own +// `scaleMin..scaleMax` (e.g. 1..2 for `factor_conflict`). See +// `resolveRatedFactors` in `src/lib/mood/tag-links.ts`. +const ratedFactor = z.object({ + key: z.string().max(60), + rating: z.number().int().min(1).max(5), +}); +const ratedFactors = z.array(ratedFactor).max(30); + export const createMoodEntrySchema = z.object({ mood: moodLevelEnum, tags: z.array(z.string().max(50)).max(20).optional(), @@ -91,6 +108,11 @@ export const createMoodEntrySchema = z.object({ // unknown keys are dropped silently (the catalog is the source of // truth, a stale client can't mint a tag). tagKeys: structuredTagKeys.optional(), + // v1.12.0 — rated factors scored 1..5 (or the factor's own scale). + // Parallel to the binary `tagKeys`; persisted on + // `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are + // rejected (422) / dropped server-side per the catalog. + ratedFactors: ratedFactors.optional(), // v1.4.30 H-5 — first-class free-text note. Replaces the // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. @@ -105,6 +127,9 @@ export const updateMoodEntrySchema = z.object({ // v1.8.5 — full replacement of the structured-tag set when present. // `null` clears every link; omit to leave links untouched. tagKeys: structuredTagKeys.nullable().optional(), + // v1.12.0 — full replacement of the rated-factor set when present. + // `null` clears every rated link; omit to leave them untouched. + ratedFactors: ratedFactors.nullable().optional(), note: z.string().max(500).nullable().optional(), moodLoggedAt: z.iso .datetime({ offset: true }) diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts index 57999a785..ef3577926 100644 --- a/tests/integration/mood-entries-bulk.test.ts +++ b/tests/integration/mood-entries-bulk.test.ts @@ -185,6 +185,76 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => { expect(keys).toEqual(["fast_food", "movies"]); }); + it("persists a rated factor with its score on the link (v1.12.0)", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + ratedFactors: [ + { key: "factor_work", rating: 4 }, + { key: "factor_conflict", rating: 2 }, + ], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { inserted: number } }; + expect(json.data.inserted).toBe(1); + + const entry = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + const links = await getPrismaClient().moodEntryTagLink.findMany({ + where: { moodEntryId: entry.id }, + select: { rating: true, moodTag: { select: { key: true, kind: true } } }, + orderBy: { moodTag: { key: "asc" } }, + }); + expect( + links.map((l) => ({ + key: l.moodTag.key, + kind: l.moodTag.kind, + rating: l.rating, + })), + ).toEqual([ + { key: "factor_conflict", kind: "RATED", rating: 2 }, + { key: "factor_work", kind: "RATED", rating: 4 }, + ]); + }); + + it("marks the entry skipped when a rated factor is out of scale (v1.12.0)", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + // factor_conflict scale is 1..2; rating 5 is out of range. + ratedFactors: [{ key: "factor_conflict", rating: 5 }], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(json.data.entries[0].status).toBe("skipped"); + + // The mood row upserted, but no rated link landed. + const entry = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + const ratedCount = await getPrismaClient().moodEntryTagLink.count({ + where: { moodEntryId: entry.id, rating: { not: null } }, + }); + expect(ratedCount).toBe(0); + }); + it("ignores tagKeys that resolve to no catalog rows", async () => { const { POST } = await import("@/app/api/mood-entries/bulk/route"); const res = await POST(