mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(settings): list layout and preview switch serve the person, atomically
Both routes resolved through bare requireAuth, which refuses a session acting for another record, so the list view controls and the preview switch failed in exactly the context they are used in. They are the person's own presentation and now resolve through requireActorAuth, admitted on the actor-surface list with their reasons. The layout PUT and the preview PATCH read and write the stored blob under a row lock in one transaction, so two saves at once no longer start from the same value and drop each other's change.
This commit is contained in:
1 parent
557865acca
commit
90c9a4e2d0
4 files changed
+173
-35
No files matched your search
@@ -8,11 +8,17 @@
|
||||
* keeps for its fields. The blob lives on its own `User` column
|
||||
* (`module_list_layout_json`).
|
||||
*
|
||||
* Both halves are the caller's own presentation. 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.
|
||||
* 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, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -25,8 +31,11 @@ import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readStored(userId: string): Promise<unknown> {
|
||||
const row = await prisma.user.findUnique({
|
||||
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 },
|
||||
});
|
||||
@@ -34,7 +43,7 @@ async function readStored(userId: string): Promise<unknown> {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireActorAuth();
|
||||
const layout = resolveModuleListLayout(await readStored(user.id));
|
||||
annotate({
|
||||
action: { name: "settings.module-list-layout.read" },
|
||||
@@ -46,7 +55,7 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
// 500 ids of up to 64 characters plus the envelope.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
@@ -66,18 +75,21 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
// 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 stored = storedModuleListLayout(await readStored(user.id));
|
||||
const nextChoices = {
|
||||
...stored[list],
|
||||
...(view ? { view } : {}),
|
||||
...(order ? { order: [...new Set(order)] } : {}),
|
||||
...(sortDir ? { sortDir } : {}),
|
||||
};
|
||||
const nextStored = { ...stored, [list]: nextChoices };
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { moduleListLayoutJson: toJson({ version: 1, ...nextStored }) },
|
||||
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 } : {}),
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
* the flags the body names and keeps the rest; unknown flags are refused
|
||||
* with the multi-issue 422 so a typo cannot silently do nothing. The blob
|
||||
* lives on its own `User` column (`preview_features_json`).
|
||||
*
|
||||
* The opt-ins are the caller's own, so this is an actor surface
|
||||
* (`requireActorAuth`): a browser acting for another record keeps reaching
|
||||
* the person's own switch. The PATCH reads and writes under a row lock, so two
|
||||
* flags switched at once both stay switched.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -19,8 +24,11 @@ import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readStored(userId: string): Promise<unknown> {
|
||||
const row = await prisma.user.findUnique({
|
||||
async function readStored(
|
||||
userId: string,
|
||||
db: Pick<typeof prisma, "user"> = prisma,
|
||||
): Promise<unknown> {
|
||||
const row = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { previewFeaturesJson: true },
|
||||
});
|
||||
@@ -32,7 +40,7 @@ function enabledFlags(features: PreviewFeatures): string[] {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireActorAuth();
|
||||
const features = resolvePreviewFeatures(await readStored(user.id));
|
||||
annotate({
|
||||
action: { name: "settings.preview-features.read" },
|
||||
@@ -42,7 +50,7 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
@@ -57,14 +65,17 @@ export const PATCH = apiHandler(async (request: Request) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const current = resolvePreviewFeatures(await readStored(user.id));
|
||||
const next: PreviewFeatures = {
|
||||
topics: parsed.data.topics ?? current.topics,
|
||||
};
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { previewFeaturesJson: toJson(next) },
|
||||
const next = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`;
|
||||
const current = resolvePreviewFeatures(await readStored(user.id, tx));
|
||||
const merged: PreviewFeatures = {
|
||||
topics: parsed.data.topics ?? current.topics,
|
||||
};
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { previewFeaturesJson: toJson(merged) },
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user