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

@@ -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(