mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
793602f464
commit
81096b9553
20 files changed
+1100
-43
No files matched your search
@@ -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.
|
||||
+8
-2
@@ -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": {
|
||||
|
||||
+8
-2
@@ -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": {
|
||||
|
||||
+8
-2
@@ -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": {
|
||||
|
||||
+8
-2
@@ -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": {
|
||||
|
||||
+8
-2
@@ -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": {
|
||||
|
||||
+8
-2
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof import("@/lib/mood/tag-links")>(
|
||||
"@/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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/mood/tag-links")>(
|
||||
"@/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");
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
// 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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, boolean> } };
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
PartyPopper,
|
||||
Pizza,
|
||||
Plane,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
Swords,
|
||||
Tag,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
@@ -84,7 +86,9 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
|
||||
PartyPopper,
|
||||
Pizza,
|
||||
Plane,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
Swords,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
Trees,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+148
-1
@@ -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<ResolvedRatedFactor[]> {
|
||||
if (factors.length === 0) return [];
|
||||
// Last-writer-wins on a duplicate key in the same submission.
|
||||
const byKey = new Map<string, number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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:<text>"]` 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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user