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

@@ -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 })