fix(anamnesis): serialise the AI switch writes so a PUT cannot resurrect a section

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:45:06 +02:00
1 parent 8ae60e2cb3
commit 23f008c8bb
5 files changed
+74

No files matched your search

@@ -50,6 +50,7 @@ const { profile, userUpdate, transaction } = vi.hoisted(() => {
};
const userUpdate = vi.fn(async () => ({}));
const tx = {
$queryRaw: vi.fn(async () => []),
userHealthProfile: profile,
user: { update: userUpdate },
};
@@ -44,6 +44,7 @@ const {
const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) }));
const userUpdate = vi.fn(async () => ({}));
const tx = {
$queryRaw: vi.fn(async () => []),
userHealthProfile: {
findUnique: profileFindUnique,
upsert: profileUpsert,
@@ -246,6 +247,7 @@ describe("about-me module gate", () => {
transaction.mockImplementationOnce(
async (callback: (client: unknown) => unknown) => {
const result = await callback({
$queryRaw: vi.fn(async () => []),
userHealthProfile: {
findUnique: profileFindUnique,
upsert: txProfileUpsert,
+4
View File
@@ -42,6 +42,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { lockAiSections } from "@/lib/profile/life-circumstances";
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
@@ -246,6 +247,9 @@ export const PUT = apiHandler(async (req: Request) => {
// A PUT here replaces the eight sections it lists and leaves the
// life-circumstance switches (v1.43) exactly as stored.
if (parsed.data.aiIncludedSections !== undefined) {
// Taken before the read, so a switch changed concurrently is read
// after it commits and carried over as it now stands.
await lockAiSections(tx, user.id);
const stored = await tx.userHealthProfile.findUnique({
where: { userId: user.id },
select: { aiIncludedSections: true },
+20
View File
@@ -445,6 +445,25 @@ function lifeSwitches(
) as Record<LifeAiSection, boolean>;
}
/**
* Serialise every read-modify-write of a profile's AI switches. Two routes
* write the one array: this module's switch route and the about-me PUT, which
* replaces its eight sections and carries the life switches over as stored.
* Without a lock, a PUT that read the array before a concurrent switch-off
* committed wrote the old value back and turned the section on again. A
* transaction-scoped advisory lock covers the first write too, when there is
* no row yet to lock.
*/
export async function lockAiSections(
tx: Prisma.TransactionClient,
userId: string,
): Promise<void> {
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${`ai-sections:${userId}`}, 0))
`;
}
/**
* Switch life-circumstance sections in or out of the AI inclusion list. Only
* the named sections change; the other stored values, the five about-me
@@ -459,6 +478,7 @@ export async function updateLifeAiSections(
base?: Date,
): Promise<LifeAiSectionsOutcome> {
return prisma.$transaction(async (tx) => {
await lockAiSections(tx, userId);
const row = await tx.userHealthProfile.findUnique({
where: { userId },
select: { aiIncludedSections: true, updatedAt: true },