chore(release): v1.13.0 — per-user custom mood tags + catalogue hide

Let a user shape their own mood-tag set on top of the global catalogue:
create custom tags (label encrypted at rest, icon from a fixed allow-list),
and hide built-in tags per user. Custom tags live in `mood_tags` with a
non-null `user_id` and a `custom:<uuid>` key; `GET /api/mood/tags` returns
the effective per-user set (catalogue − hidden + own customs) with new
`custom` + `label` fields, and `?include=hidden` surfaces hidden catalogue
tags for the management screen. The write path resolves a `custom:` key only
against the caller's own rows — a foreign custom key is dropped, never linked.
Delete soft-deactivates by default (history intact); `?purge=true` removes
the row and its links. BINARY-only for v1.

New: POST/PATCH/DELETE /api/mood/tags/custom[/:key], PUT /api/mood/tags/:key/hidden,
migration 0127 (user_id + label_encrypted columns, mood_tag_hidden table,
`custom` category). Integration setup snapshots + restores the seeded catalogue
because the new mood_tags→users FK makes TRUNCATE users CASCADE wipe it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 20:50:05 +02:00
1 parent 09bdd49791
commit f1329b24e1
21 files changed
+934 -121

No files matched your search

@@ -0,0 +1,63 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { hideCatalogueTagSchema, isCustomTagKey } from "@/lib/mood/custom-tags";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ key: string }> };
/**
* v1.13.0 — hide / show a CATALOGUE tag for the calling user.
*
* `PUT /api/mood/tags/:key/hidden` — body `{ hidden: boolean }`. Inserts /
* removes a `mood_tag_hidden` row so the effective `GET /api/mood/tags` omits
* the tag for this user without touching the global catalogue. Custom tags
* are not hidden this way — flip their own `isActive` via the custom PATCH.
*/
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { key } = await params;
if (isCustomTagKey(key)) {
return apiError(
"Custom tags are hidden via their isActive flag, not this route",
400,
);
}
const parsed = hideCatalogueTagSchema.safeParse(await request.json());
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Resolve against the catalogue (user_id NULL) only.
const tag = await prisma.moodTag.findFirst({
where: { key, userId: null },
select: { id: true },
});
if (!tag) return apiError("Catalogue tag not found", 404);
if (parsed.data.hidden) {
await prisma.moodTagHidden.upsert({
where: {
userId_moodTagId: { userId: user.id, moodTagId: tag.id },
},
create: { userId: user.id, moodTagId: tag.id },
update: {},
});
} else {
await prisma.moodTagHidden.deleteMany({
where: { userId: user.id, moodTagId: tag.id },
});
}
annotate({
action: { name: "mood.tag.hidden.set" },
meta: { hidden: parsed.data.hidden },
});
return apiSuccess({ key, hidden: parsed.data.hidden });
},
);
@@ -0,0 +1,131 @@
/**
* v1.13.0 — custom mood-tag CRUD route guards: create cap, ownership 404 on
* edit/delete, soft-delete vs purge, and the custom-key rejection on the
* catalogue-hide route.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => ({
moodTag: {
count: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
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 { POST } from "../custom/route";
import { PATCH, DELETE } from "../custom/[key]/route";
import { PUT } from "../[key]/hidden/route";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "t", role: "USER" as const },
};
function jsonReq(url: string, method: string, body: unknown) {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/mood/tags/custom", () => {
it("creates a custom tag and returns it (custom:true, decrypted label)", async () => {
db.moodTag.count.mockResolvedValue(3);
db.moodTag.create.mockResolvedValue({
key: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false,
});
const res = await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "Date night", icon: "Heart" }));
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { custom: boolean; label: string; labelKey: null; key: string } };
expect(body.data).toMatchObject({ custom: true, label: "Date night", labelKey: null, key: "custom:abc" });
// Label is stored encrypted, never plaintext.
expect(db.moodTag.create.mock.calls[0][0].data.labelEncrypted).toBe("enc:Date night");
});
it("rejects over the per-user cap with 422", async () => {
db.moodTag.count.mockResolvedValue(50);
const res = await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }));
expect(res.status).toBe(422);
expect(db.moodTag.create).not.toHaveBeenCalled();
});
});
describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
const params = (key: string) => ({ params: Promise.resolve({ key }) });
it("404s when the key is not the caller's own custom tag", async () => {
db.moodTag.findFirst.mockResolvedValue(null);
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { isActive: false }),
params("custom:x"),
);
expect(res.status).toBe(404);
});
it("404s a non-custom key without touching the DB", async () => {
const res = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/happy"), params("happy"));
expect(res.status).toBe(404);
expect(db.moodTag.findFirst).not.toHaveBeenCalled();
});
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
const soft = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"), params("custom:abc"));
expect(soft.status).toBe(200);
expect(db.moodTag.update).toHaveBeenCalledWith({ where: { id: "id1" }, data: { isActive: false } });
expect(db.moodTag.delete).not.toHaveBeenCalled();
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
const purge = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc?purge=true"), params("custom:abc"));
expect(purge.status).toBe(200);
expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
});
});
describe("PUT /api/mood/tags/:key/hidden", () => {
it("rejects a custom key with 400 (custom tags hide via isActive)", async () => {
const res = await PUT(
jsonReq("http://localhost/api/mood/tags/custom:abc/hidden", "PUT", { hidden: true }),
{ params: Promise.resolve({ key: "custom:abc" }) },
);
expect(res.status).toBe(400);
});
it("upserts a hide row for a catalogue tag", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "t_sad" });
const res = await PUT(
jsonReq("http://localhost/api/mood/tags/sad/hidden", "PUT", { hidden: true }),
{ params: Promise.resolve({ key: "sad" }) },
);
expect(res.status).toBe(200);
expect(db.moodTagHidden.upsert).toHaveBeenCalled();
});
});
+89 -64
View File
@@ -1,18 +1,31 @@
/**
* 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.
* v1.12.0 / v1.13.0 — `GET /api/mood/tags` exposes the rated-factor metadata
* (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the v1.13.0 effective
* per-user set: own custom tags merged in (label decrypted, `custom: true`),
* hidden catalogue tags omitted by default and surfaced under
* `?include=hidden`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const categoryFindMany = vi.fn();
const tagFindMany = vi.fn();
const hiddenFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) },
moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) },
moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) },
},
}));
// Identity-ish crypto so the decrypted custom label is asserted in plaintext.
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -38,77 +51,89 @@ const SESSION_OK = {
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const CATEGORIES = [
{ id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile" },
{ id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag" },
];
const TAGS = [
{ id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null },
{ id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null },
{ id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: "user-1", labelEncrypted: "enc:Migräne" },
];
interface TagOut {
key: string;
labelKey: string | null;
label: string | null;
custom: boolean;
kind: string;
scaleMin: number;
scaleMax: number;
inverse: boolean;
hidden?: boolean;
}
interface Body {
data: { categories: Array<{ key: string; tags: TagOut[] }> };
}
function req(url = "http://localhost/api/mood/tags") {
return new NextRequest(url);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
categoryFindMany.mockResolvedValue(CATEGORIES);
tagFindMany.mockResolvedValue(TAGS);
hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]);
});
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,
},
],
},
]);
function flat(body: Body): Record<string, TagOut> {
const out: Record<string, TagOut> = {};
for (const c of body.data.categories) for (const t of c.tags) out[t.key] = t;
return out;
}
const res = await GET();
describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => {
it("emits rated-factor metadata + custom/label fields and omits hidden by default", async () => {
const res = await GET(req());
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;
}>;
}>;
};
};
const tags = flat((await res.json()) as Body);
// 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,
// Catalogue tag: custom=false, label=null, labelKey kept, metadata present.
expect(tags.happy).toMatchObject({
custom: false,
label: null,
labelKey: "mood.tag.happy",
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
});
expect(tags.happy.hidden).toBeUndefined();
// Custom tag merged in: custom=true, decrypted label, labelKey null.
expect(tags["custom:abc"]).toMatchObject({
custom: true,
label: "Migräne",
labelKey: null,
});
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);
// Hidden catalogue tag omitted by default.
expect(tags.sad).toBeUndefined();
const conflict = body.data.categories[0].tags[1];
expect(conflict.scaleMax).toBe(2);
expect(conflict.inverse).toBe(true);
// Tag query scopes to catalogue OR the caller's own customs.
const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
});
it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => {
const res = await GET(req("http://localhost/api/mood/tags?include=hidden"));
const tags = flat((await res.json()) as Body);
expect(tags.sad).toMatchObject({ hidden: true, custom: false });
expect(tags.happy).toMatchObject({ hidden: false });
// Custom tags never carry a hidden flag.
expect(tags["custom:abc"].hidden).toBeUndefined();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
updateCustomTagSchema,
encryptCustomLabel,
decryptCustomLabel,
isCustomTagKey,
} from "@/lib/mood/custom-tags";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ key: string }> };
/**
* v1.13.0 — update / delete a per-user custom mood tag.
*
* Both handlers resolve the `custom:`-prefixed key against the CALLER's own
* rows only — another user's key (or a catalogue key) is a 404, so a tag can
* never be edited or removed across the ownership boundary.
*/
/** `PATCH /api/mood/tags/custom/:key` — rename / recolour / (de)activate. */
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { key } = await params;
if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404);
const parsed = updateCustomTagSchema.safeParse(await request.json());
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const owned = await prisma.moodTag.findFirst({
where: { key, userId: user.id },
select: { id: true },
});
if (!owned) return apiError("Custom tag not found", 404);
const updated = await prisma.moodTag.update({
where: { id: owned.id },
data: {
...(parsed.data.label !== undefined
? { labelEncrypted: encryptCustomLabel(parsed.data.label) }
: {}),
...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}),
...(parsed.data.isActive !== undefined
? { isActive: parsed.data.isActive }
: {}),
},
select: {
key: true,
icon: true,
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
isActive: true,
labelEncrypted: true,
},
});
annotate({ action: { name: "mood.tag.custom.update" } });
return apiSuccess({
key: updated.key,
labelKey: null,
label: decryptCustomLabel(updated.labelEncrypted),
icon: updated.icon,
kind: updated.kind,
scaleMin: updated.scaleMin,
scaleMax: updated.scaleMax,
inverse: updated.inverse,
isActive: updated.isActive,
custom: true,
});
},
);
/**
* `DELETE /api/mood/tags/custom/:key` — soft-deactivate by default (history
* intact); `?purge=true` hard-deletes the row and cascades its entry links.
*/
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { key } = await params;
if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404);
const owned = await prisma.moodTag.findFirst({
where: { key, userId: user.id },
select: { id: true },
});
if (!owned) return apiError("Custom tag not found", 404);
const purge = request.nextUrl.searchParams.get("purge") === "true";
if (purge) {
// FK cascade removes the `mood_entry_tag_links` rows too.
await prisma.moodTag.delete({ where: { id: owned.id } });
} else {
await prisma.moodTag.update({
where: { id: owned.id },
data: { isActive: false },
});
}
annotate({ action: { name: "mood.tag.custom.delete" }, meta: { purge } });
return apiSuccess({ key, purged: purge });
},
);
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
createCustomTagSchema,
mintCustomTagKey,
encryptCustomLabel,
CUSTOM_CATEGORY_ID,
MAX_CUSTOM_TAGS_PER_USER,
} from "@/lib/mood/custom-tags";
export const dynamic = "force-dynamic";
/**
* v1.13.0 — create a per-user custom mood tag.
*
* `POST /api/mood/tags/custom` — body `{ label, icon?, categoryKey? }`. Mints
* a `custom:<uuid>` key, encrypts the label at rest, and stores a BINARY tag
* under the global `custom` category owned by the caller. Capped per user.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = createCustomTagSchema.safeParse(await request.json());
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const activeCount = await prisma.moodTag.count({
where: { userId: user.id, isActive: true },
});
if (activeCount >= MAX_CUSTOM_TAGS_PER_USER) {
return apiError(
`Custom tag limit reached (${MAX_CUSTOM_TAGS_PER_USER})`,
422,
);
}
const key = mintCustomTagKey();
const created = await prisma.moodTag.create({
data: {
categoryId: CUSTOM_CATEGORY_ID,
key,
// Catalogue rows resolve `labelKey` against the locale; a custom tag
// renders its decrypted `label` instead, so labelKey just mirrors the
// key for a stable, non-empty value.
labelKey: key,
labelEncrypted: encryptCustomLabel(parsed.data.label),
icon: parsed.data.icon ?? null,
kind: "BINARY",
sortOrder: activeCount,
userId: user.id,
},
select: { key: true, icon: true, kind: true, scaleMin: true, scaleMax: true, inverse: true },
});
annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon } });
return apiSuccess(
{
key: created.key,
labelKey: null,
label: parsed.data.label,
icon: created.icon,
kind: created.kind,
scaleMin: created.scaleMin,
scaleMax: created.scaleMax,
inverse: created.inverse,
custom: true,
},
201,
);
});
+98 -37
View File
@@ -1,55 +1,116 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { decryptCustomLabel } from "@/lib/mood/custom-tags";
export const dynamic = "force-dynamic";
/**
* v1.8.5 — structured mood-tag taxonomy catalog.
* v1.8.5 / v1.13.0 — the EFFECTIVE per-user mood-tag taxonomy.
*
* Returns the active Category -> Tag tree the mood-logging form renders
* as a pick-from-catalog capture surface. Global reference data (one
* shared catalog for the deployment, seeded by migration 0101), so the
* read carries no per-user filter and no encryption. The `labelKey`
* fields resolve client-side against the active locale.
* Returns the active Category → Tag tree the mood-logging form renders as a
* pick-from-catalogue surface. The catalogue is global reference data (seeded
* by migration); v1.13.0 layers two per-user adjustments on top so the read
* is now user-scoped:
* - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys)
* appear under the seeded `custom` category, their label decrypted;
* - catalogue tags the user hid (`mood_tag_hidden`) are omitted — unless
* `?include=hidden` is set, in which case they're returned with
* `hidden: true` so the management screen can toggle them back on.
*
* Every tag carries `custom: boolean` + `label: string | null`: render
* `label` for a custom tag, resolve `labelKey` against the locale for a
* catalogue tag. Backward-compatible — a client that ignores the new fields
* just sees the same categories → tags shape.
*/
export const GET = apiHandler(async () => {
// Auth-gated like every app route — the catalog is not public, but it
// is identical for every authenticated user.
await requireAuth();
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const includeHidden =
request.nextUrl.searchParams.get("include") === "hidden";
const categories = await prisma.moodTagCategory.findMany({
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: {
key: true,
labelKey: true,
icon: true,
tags: {
where: { isActive: true },
orderBy: { sortOrder: "asc" },
// 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,
},
const [categories, tags, hiddenRows] = await Promise.all([
prisma.moodTagCategory.findMany({
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: { id: true, key: true, labelKey: true, icon: true },
}),
// Catalogue tags (user_id NULL) + this user's own custom tags. Another
// user's customs are excluded by construction.
prisma.moodTag.findMany({
where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] },
orderBy: { sortOrder: "asc" },
select: {
id: true,
categoryId: true,
key: true,
labelKey: true,
icon: true,
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
userId: true,
labelEncrypted: true,
},
},
});
}),
prisma.moodTagHidden.findMany({
where: { userId: user.id },
select: { moodTagId: true },
}),
]);
const hiddenIds = new Set(hiddenRows.map((r) => r.moodTagId));
const tagsByCategory = new Map<string, typeof tags>();
for (const t of tags) {
const isCustom = t.userId !== null;
const isHidden = !isCustom && hiddenIds.has(t.id);
if (isHidden && !includeHidden) continue;
const list = tagsByCategory.get(t.categoryId) ?? [];
list.push(t);
tagsByCategory.set(t.categoryId, list);
}
const responseCategories = categories
.map((c) => {
const catTags = (tagsByCategory.get(c.id) ?? []).map((t) => {
const isCustom = t.userId !== null;
return {
key: t.key,
labelKey: isCustom ? null : t.labelKey,
label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null,
icon: t.icon,
kind: t.kind,
scaleMin: t.scaleMin,
scaleMax: t.scaleMax,
inverse: t.inverse,
custom: isCustom,
...(includeHidden && !isCustom
? { hidden: hiddenIds.has(t.id) }
: {}),
};
});
return {
key: c.key,
labelKey: c.labelKey,
icon: c.icon,
tags: catTags,
};
})
// Drop a category with no visible tags (e.g. `custom` for a user with
// none yet) so the picker grid stays tight.
.filter((c) => c.tags.length > 0);
annotate({
action: { name: "mood.tags.catalog.read" },
meta: { category_count: categories.length },
meta: {
category_count: responseCategories.length,
include_hidden: includeHidden,
},
});
return apiSuccess({ categories });
return apiSuccess({ categories: responseCategories });
});