feat(mood): add hobbies and nutrition tag categories, persist bulk tagKeys

Deepen the structured mood-tag taxonomy and close a bulk-ingest data-loss
gap the iOS client has been blocked on.

Taxonomy (migration 0118, additive + idempotent, mirrors the 0101 seed):
- New `hobbies` category: movies, reading, gaming, music, outdoors.
- Nutrition-detail tags appended to the existing `health` category:
  fast_food, no_sweets, big_meal (alongside the original ate_well).
- Lucide icon names seeded per row and registered in the client icon
  map; labelKeys added for every new category and tag across all six
  locales.

Bulk tagKeys (data-loss fix):
- `POST /api/mood-entries/bulk` previously Zod-stripped `tagKeys`, so
  structured taxonomy links were silently dropped on the adopt-on-pair
  backfill while the single-entry route persisted them correctly. Add
  `tagKeys` to the bulk entry schema and write the `MoodEntryTagLink`
  rows per entry via the shared `createTagLinks` helper — unknown keys
  are dropped against the catalog, the join insert skips duplicates so a
  re-posted batch stays idempotent.

Tests cover the bulk tagKeys round-trip (links created, unknown keys
ignored, over-long arrays rejected at the schema boundary) at the unit
level and against real Postgres.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 22:36:58 +02:00
1 parent 686bc1bf30
commit 14469a6de9
11 files changed
+296 -12

No files matched your search

+11 -2
View File
@@ -510,7 +510,8 @@
"sleep": "Schlaf",
"health": "Gesundheit",
"social": "Soziales",
"work": "Arbeit"
"work": "Arbeit",
"hobbies": "Hobbys"
},
"tag": {
"happy": "glücklich",
@@ -542,7 +543,15 @@
"overtime": "Überstunden",
"dayOff": "Feierabend",
"travel": "Reise",
"sickDay": "Krankheitstag"
"sickDay": "Krankheitstag",
"movies": "Filme",
"reading": "gelesen",
"gaming": "gezockt",
"music": "Musik",
"outdoors": "draußen",
"fastFood": "Fast Food",
"noSweets": "keine Süßigkeiten",
"bigMeal": "üppige Mahlzeit"
}
},
"medications": {
+11 -2
View File
@@ -510,7 +510,8 @@
"sleep": "Sleep",
"health": "Health",
"social": "Social",
"work": "Work"
"work": "Work",
"hobbies": "Hobbies"
},
"tag": {
"happy": "happy",
@@ -542,7 +543,15 @@
"overtime": "overtime",
"dayOff": "day off",
"travel": "travel",
"sickDay": "sick day"
"sickDay": "sick day",
"movies": "movies",
"reading": "reading",
"gaming": "gaming",
"music": "music",
"outdoors": "outdoors",
"fastFood": "fast food",
"noSweets": "no sweets",
"bigMeal": "big meal"
}
},
"medications": {
+11 -2
View File
@@ -510,7 +510,8 @@
"sleep": "Sueño",
"health": "Salud",
"social": "Social",
"work": "Trabajo"
"work": "Trabajo",
"hobbies": "Aficiones"
},
"tag": {
"happy": "feliz",
@@ -542,7 +543,15 @@
"overtime": "horas extra",
"dayOff": "día libre",
"travel": "viaje",
"sickDay": "día de baja"
"sickDay": "día de baja",
"movies": "películas",
"reading": "lectura",
"gaming": "videojuegos",
"music": "música",
"outdoors": "aire libre",
"fastFood": "comida rápida",
"noSweets": "sin dulces",
"bigMeal": "comilona"
}
},
"medications": {
+11 -2
View File
@@ -510,7 +510,8 @@
"sleep": "Sommeil",
"health": "Santé",
"social": "Social",
"work": "Travail"
"work": "Travail",
"hobbies": "Loisirs"
},
"tag": {
"happy": "heureux",
@@ -542,7 +543,15 @@
"overtime": "heures sup",
"dayOff": "jour de repos",
"travel": "voyage",
"sickDay": "jour de maladie"
"sickDay": "jour de maladie",
"movies": "films",
"reading": "lecture",
"gaming": "jeux vidéo",
"music": "musique",
"outdoors": "plein air",
"fastFood": "fast-food",
"noSweets": "sans sucreries",
"bigMeal": "gros repas"
}
},
"medications": {
+11 -2
View File
@@ -510,7 +510,8 @@
"sleep": "Sonno",
"health": "Salute",
"social": "Sociale",
"work": "Lavoro"
"work": "Lavoro",
"hobbies": "Hobby"
},
"tag": {
"happy": "felice",
@@ -542,7 +543,15 @@
"overtime": "straordinari",
"dayOff": "giorno libero",
"travel": "viaggio",
"sickDay": "giorno di malattia"
"sickDay": "giorno di malattia",
"movies": "film",
"reading": "lettura",
"gaming": "videogiochi",
"music": "musica",
"outdoors": "all'aperto",
"fastFood": "fast food",
"noSweets": "niente dolci",
"bigMeal": "pasto abbondante"
}
},
"medications": {
+11 -2
View File
@@ -510,7 +510,8 @@
"sleep": "Sen",
"health": "Zdrowie",
"social": "Społeczne",
"work": "Praca"
"work": "Praca",
"hobbies": "Hobby"
},
"tag": {
"happy": "szczęśliwy",
@@ -542,7 +543,15 @@
"overtime": "nadgodziny",
"dayOff": "dzień wolny",
"travel": "podróż",
"sickDay": "dzień chorobowy"
"sickDay": "dzień chorobowy",
"movies": "filmy",
"reading": "czytanie",
"gaming": "gry",
"music": "muzyka",
"outdoors": "na dworze",
"fastFood": "fast food",
"noSweets": "bez słodyczy",
"bigMeal": "obfity posiłek"
}
},
"medications": {
@@ -0,0 +1,62 @@
-- v1.12.0 — deepen the structured mood-tag taxonomy.
--
-- Purely additive on top of migration 0101's catalog: one new `hobbies`
-- category (movies / reading / gaming / music / outdoors) plus a set of
-- nutrition-detail tags appended to the existing `health` category
-- (`fast_food` / `no_sweets` / `big_meal`, alongside the original
-- `ate_well`). No table changes, no per-user data touched — this only
-- seeds new global catalog rows.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales), same convention as the original seed. The
-- inserts use deterministic ids + ON CONFLICT upsert by `key` so a
-- re-run is idempotent, mirroring 0101 exactly.
-- ─── New category: hobbies ───────────────────────────────────────────
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_hobbies', 'hobbies', 'mood.tagCategory.hobbies', 'Palette', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_movies', 'mtc_hobbies', 'movies', 'mood.tag.movies', 'Film', 0, 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";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_reading', 'mtc_hobbies', 'reading', 'mood.tag.reading', 'BookOpen', 1, 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";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_gaming', 'mtc_hobbies', 'gaming', 'mood.tag.gaming', 'Gamepad2', 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";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_music', 'mtc_hobbies', 'music', 'mood.tag.music', 'Music', 3, 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";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_outdoors', 'mtc_hobbies', 'outdoors', 'mood.tag.outdoors', 'Trees', 4, 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";
-- ─── Nutrition-detail tags appended to the existing `health` category ─
-- Mirrors the existing `ate_well` (sort_order 1) under `mtc_health`; the
-- original health tags occupy sort_order 0-4, so these slot in at 5-7.
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_fast_food', 'mtc_health', 'fast_food', 'mood.tag.fastFood', 'Pizza', 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";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_no_sweets', 'mtc_health', 'no_sweets', 'mood.tag.noSweets', 'CandyOff', 6, 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";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_big_meal', 'mtc_health', 'big_meal', 'mood.tag.bigMeal', 'UtensilsCrossed', 7, 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";
@@ -15,6 +15,14 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/mood/tag-links", () => ({
createTagLinks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/moodlog/push", () => ({
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -52,6 +60,8 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { createTagLinks } from "@/lib/mood/tag-links";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -71,6 +81,15 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
// `vi.resetAllMocks()` above wipes the factory default, so the
// best-effort reverse-sync push must be re-stubbed to a resolved
// promise — the route calls `.catch()` on its return value.
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
pushed: 0,
skipped: 0,
status: "skipped",
});
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
@@ -153,3 +172,55 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
beforeEach(() => {
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-1",
} as never);
});
it("persists structured tag links for an entry carrying tagKeys", async () => {
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: ["movies", "gaming"],
},
],
}),
);
expect(res.status).toBe(200);
expect(createTagLinks).toHaveBeenCalledTimes(1);
expect(createTagLinks).toHaveBeenCalledWith("entry-1", ["movies", "gaming"]);
});
it("skips the tag-link write when an entry sends no tagKeys", async () => {
const res = await POST(
postReq({
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
}),
);
expect(res.status).toBe(200);
expect(createTagLinks).not.toHaveBeenCalled();
});
it("strips an over-long tagKeys array at the schema boundary (422)", async () => {
const res = await POST(
postReq({
entries: [
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: Array.from({ length: 31 }, (_, i) => `k${i}`),
},
],
}),
);
expect(res.status).toBe(422);
expect(createTagLinks).not.toHaveBeenCalled();
});
});
+24
View File
@@ -47,6 +47,7 @@ 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";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -55,6 +56,18 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
/**
* v1.12.0 — structured-tag keys from the catalog (`mood_tags.key`),
* mirroring the single-entry `POST /api/mood-entries` contract.
* Without this the bulk path Zod-stripped the field, so iOS-sent
* taxonomy links were silently dropped on the adopt-on-pair backfill.
* The server resolves each key to a `MoodTag` row and writes the
* `MoodEntryTagLink` join; unknown keys are dropped silently (the
* catalog is the source of truth). Bounds match the single-entry
* `structuredTagKeys` schema so one entry can't fan out an unbounded
* link set.
*/
tagKeys: z.array(z.string().max(60)).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"),
@@ -204,6 +217,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
// v1.12.0 — persist structured-tag links, mirroring the
// single-entry `createTagLinks` path. Additive + idempotent:
// `createTagLinks` resolves keys against the catalog (dropping
// unknown keys) and `skipDuplicates` on the join insert keeps a
// 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);
}
if (existing) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: result.id });
+18
View File
@@ -2,16 +2,20 @@ import {
AlertTriangle,
Apple,
BedDouble,
BookOpen,
Brain,
Briefcase,
CandyOff,
CheckCircle,
Clock,
CloudMoon,
CloudSun,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
GlassWater,
HandHeart,
Heart,
@@ -22,14 +26,19 @@ import {
Meh,
Moon,
MoonStar,
Music,
Palette,
PartyPopper,
Pizza,
Plane,
Smile,
Tag,
Thermometer,
ThumbsUp,
Trees,
User,
Users,
UtensilsCrossed,
Wine,
Zap,
type LucideIcon,
@@ -46,16 +55,20 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
AlertTriangle,
Apple,
BedDouble,
BookOpen,
Brain,
Briefcase,
CandyOff,
CheckCircle,
Clock,
CloudMoon,
CloudSun,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
GlassWater,
HandHeart,
Heart,
@@ -66,13 +79,18 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
Meh,
Moon,
MoonStar,
Music,
Palette,
PartyPopper,
Pizza,
Plane,
Smile,
Thermometer,
ThumbsUp,
Trees,
User,
Users,
UtensilsCrossed,
Wine,
Zap,
};
@@ -153,6 +153,61 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
expect(stored[0].score).toBe(5);
});
it("persists structured tagKeys as MoodEntryTagLink rows (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",
// Two valid catalog keys (one from the new hobbies category,
// one nutrition-detail key) plus one unknown key that must be
// dropped silently.
tagKeys: ["movies", "fast_food", "not_a_real_tag"],
},
],
}),
);
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: { moodTag: { select: { key: true } } },
});
const keys = links.map((l) => l.moodTag.key).sort();
// The unknown key is dropped; the two catalog keys round-trip.
expect(keys).toEqual(["fast_food", "movies"]);
});
it("ignores tagKeys that resolve to no catalog rows", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
tagKeys: ["totally_unknown_key"],
},
],
}),
);
expect(res.status).toBe(200);
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: TEST_USER_ID },
});
const linkCount = await getPrismaClient().moodEntryTagLink.count({
where: { moodEntryId: entry.id },
});
expect(linkCount).toBe(0);
});
it("rejects an over-cap batch with 422 and the documented error code", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const entries = Array.from({ length: 501 }, (_, i) => ({