Files
HealthLog/src/app/api/settings/module-list-layout/route.ts
T
Marc-André Bombeck 928ade5109 feat(labs): let a person order the lab categories (#1349)
The labs list layout gains categoryOrder, a bounded allow-list of
category keys (200 entries, 64 characters each, deduplicated, malformed
values dropped on read). GET /api/labs/overview reads the caller's own
layout and answers categories already in that order, so every client
renders them as given. Listed categories come first; keys the data does
not have are ignored; a category the order does not name follows in
the default order (catalog panels, then the person's own by label, then
other). Other left at the end of the order stays last, behind any
category added later; moved anywhere else, it stays there.

Settings > Arrangement > Labs shows a category order card while
categories are on. It lists the overview's categories under their
localised labels, sends the whole order after every move, moves the
cached overview at once and refetches it. The card says how the default
order works. The OpenAPI document carries the new field.
2026-11-10 18:57:48 +01:00

108 lines
4.1 KiB
TypeScript

/**
* GET / PUT the list presentation for the labs, illness and checkups lists.
*
* GET returns every list resolved, defaults filled in, with `saved` per list
* so a client can tell a stored choice from a default. PUT changes one list
* with preserve-when-absent semantics: a body carrying only `view` keeps the
* stored order and sort, and vice versa, the contract `/api/documents/inbound/layout`
* keeps for its fields. The blob lives on its own `User` column
* (`module_list_layout_json`).
*
* Both halves are the caller's own presentation, so this is an actor surface
* (`requireActorAuth`): a browser acting for another record still reads and
* saves the person's own layout instead of being refused. Inside somebody
* else's record the ids in a saved order simply do not match and the list
* keeps the server order, so no record gate is needed here.
*
* The PUT reads and writes the blob under a row lock in one transaction, so
* two saves for different lists (two tabs, a quick view switch and a reorder)
* cannot each start from the same stored value and drop the other's change.
*/
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
MODULE_LIST_KEYS,
resolveModuleListLayout,
storedModuleListLayout,
} from "@/lib/settings/module-list-layout";
import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs";
export const dynamic = "force-dynamic";
async function readStored(
userId: string,
db: Pick<typeof prisma, "user"> = prisma,
): Promise<unknown> {
const row = await db.user.findUnique({
where: { id: userId },
select: { moduleListLayoutJson: true },
});
return row?.moduleListLayoutJson;
}
export const GET = apiHandler(async () => {
const { user } = await requireActorAuth();
const layout = resolveModuleListLayout(await readStored(user.id));
annotate({
action: { name: "settings.module-list-layout.read" },
meta: {
saved: MODULE_LIST_KEYS.filter((list) => layout.lists[list].saved),
},
});
return apiSuccess(layout);
});
export const PUT = apiHandler(async (request: Request) => {
const { user } = await requireActorAuth();
// 500 ids plus 200 category keys of up to 64 characters, plus the envelope.
const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, {
maxBytes: 64 * 1024,
action: "settings.module-list-layout.validation-failed",
});
if (!parsed.ok) return parsed.error;
const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } =
parsed.data;
// Preserve-when-absent: one stored read fills whichever field the body
// left out, and every other list stays as stored. Only fields a person has
// chosen are written, so a field never picked keeps following the default.
const nextStored = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`;
const stored = storedModuleListLayout(await readStored(user.id, tx));
const nextChoices = {
...stored[list],
...(view ? { view } : {}),
...(order ? { order: [...new Set(order)] } : {}),
...(sortDir ? { sortDir } : {}),
...(groupBy ? { groupBy } : {}),
...(groupsOpen ? { groupsOpen } : {}),
...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}),
};
const next = { ...stored, [list]: nextChoices };
await tx.user.update({
where: { id: user.id },
data: { moduleListLayoutJson: toJson({ version: 1, ...next }) },
});
return next;
});
const layout = resolveModuleListLayout(nextStored);
annotate({
action: { name: "settings.module-list-layout.update" },
meta: {
list,
view: layout.lists[list].view,
sortDir: layout.lists[list].sortDir,
groupBy: layout.lists[list].groupBy,
groupsOpen: layout.lists[list].groupsOpen,
orderLength: layout.lists[list].order.length,
categoryOrderLength: layout.lists[list].categoryOrder.length,
},
});
return apiSuccess(layout);
});