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:
Marc-André Bombeck committed 2026-06-04 23:09:03 +02:00
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+21
View File
@@ -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");
});
});
+29 -2
View File
@@ -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) {
+70 -18
View File
@@ -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);
});
});
+13 -1
View File
@@ -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,
},
},
},
});
+4
View File
@@ -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,
+110
View File
@@ -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
View File
@@ -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,
});
}
}
+25
View File
@@ -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(