mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user