fix(cycle): honour the sensitive-data encryption toggle + harden the write path

- When the sensitive-category encryption preference is on (the default),
  encrypt the intent-revealing day-log fields (sexual activity, protection,
  pregnancy/progesterone tests, contraception) into an encrypted envelope and
  leave the queryable columns null, decrypting on read. The toggle now does
  what it says rather than storing those fields in plaintext.
- Make the day-log write a partial merge so a re-log only overwrites the
  fields it carries, never nulling untouched ones (no more data loss when the
  same day is logged again).
- Make the upsert collision-safe: a manual entry and an imported entry on the
  same date adopt one canonical row instead of throwing, and the single POST
  returns a clean conflict rather than a 500; one bad date no longer aborts a
  whole import.
- Compare the decrypted note, not the ciphertext, so an unchanged re-log is a
  true no-op and stops needless re-sync.
- Hoist cycle attribution out of the per-entry loop; map bulk failures to
  stable reason codes; audit the import goal change; mask the event name in
  discreet reminder pushes; add a one-tap hard-delete purge for all cycle data.

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-06 11:49:23 +02:00
1 parent 3e398295a3
commit ac13dd28ed
22 files changed
+770 -174

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
/**
* `DELETE /api/cycle/all` — one-click cycle hard-delete purge.
*
* Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`,
* which leaves a tombstone for the sync feed): this HARD-deletes every
* cycle row the user owns — day-logs (and their symptom links, by cascade),
* menstrual cycles, predictions, and the reminder ledger — so nothing
* reproductive persists in Postgres. The `CycleProfile` row is reset to its
* defaults (tracking left enabled) rather than dropped, so the gate and
* settings page keep working.
*
* Gated (`cycle.disabled` 403) and owner-scoped. Audited.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Hard delete in a transaction. Symptom links cascade off the day-log
// delete (the schema's onDelete: Cascade). Order child-before-parent for
// the rows that don't cascade.
const cleared = await prisma.$transaction(async (tx) => {
const dayLogs = await tx.cycleDayLog.deleteMany({
where: { userId: user.id },
});
const predictions = await tx.cyclePrediction.deleteMany({
where: { userId: user.id },
});
const cycles = await tx.menstrualCycle.deleteMany({
where: { userId: user.id },
});
return {
dayLogs: dayLogs.count,
predictions: predictions.count,
cycles: cycles.count,
};
});
await auditLog("cycle.purge", {
userId: user.id,
ipAddress: getClientIp(request),
details: cleared,
});
annotate({
action: { name: "cycle.purge" },
meta: cleared,
});
return apiSuccess({ purged: true, ...cleared });
});
+108 -39
View File
@@ -22,7 +22,9 @@ import {
safeJson,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { encrypt } from "@/lib/crypto";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
@@ -39,7 +41,16 @@ export const PATCH = apiHandler(
const existing = await prisma.cycleDayLog.findFirst({
where: { id, deletedAt: null },
select: { id: true, userId: true },
select: {
id: true,
userId: true,
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
progesteroneTest: true,
contraceptive: true,
sensitiveEncrypted: true,
},
});
if (!existing || existing.userId !== user.id) {
return apiError("Day-log not found", 404);
@@ -61,9 +72,38 @@ export const PATCH = apiHandler(
const body = parsed.data;
// Field-by-field update (no mass assignment). Every field is
// optional; an omitted field is left untouched. `note` re-encrypts;
// an explicit null clears it.
// Resolve the stored plaintext sensitive fields (from the envelope when
// the row was encrypted, else the columns) so a partial patch merges
// against the true current value.
const encryptSensitive = (await getOrCreateCycleProfile(user.id))
.sensitiveCategoryEncryption;
const stored = readStoredSensitive(existing);
const merged = {
sexualActivity:
body.sexualActivity !== undefined
? body.sexualActivity
: stored.sexualActivity,
protectedSex:
body.protectedSex !== undefined
? (body.protectedSex ?? null)
: stored.protectedSex,
pregnancyTest:
body.pregnancyTest !== undefined
? (body.pregnancyTest ?? null)
: stored.pregnancyTest,
progesteroneTest:
body.progesteroneTest !== undefined
? (body.progesteroneTest ?? null)
: stored.progesteroneTest,
contraceptive:
body.contraceptive !== undefined
? (body.contraceptive ?? null)
: stored.contraceptive,
};
// Field-by-field update (no mass assignment). Non-sensitive fields are
// written only when present; the sensitive set is re-resolved and split
// between plaintext columns and the envelope per the flag.
await prisma.cycleDayLog.update({
where: { id },
data: {
@@ -80,21 +120,16 @@ export const PATCH = apiHandler(
...(body.cervicalMucus !== undefined && {
cervicalMucus: body.cervicalMucus,
}),
...(body.sexualActivity !== undefined && {
sexualActivity: body.sexualActivity,
}),
...(body.protectedSex !== undefined && {
protectedSex: body.protectedSex,
}),
...(body.pregnancyTest !== undefined && {
pregnancyTest: body.pregnancyTest,
}),
...(body.progesteroneTest !== undefined && {
progesteroneTest: body.progesteroneTest,
}),
...(body.contraceptive !== undefined && {
contraceptive: body.contraceptive,
}),
sexualActivity: encryptSensitive ? false : merged.sexualActivity,
protectedSex: encryptSensitive ? null : merged.protectedSex,
pregnancyTest: (encryptSensitive ? null : merged.pregnancyTest) as never,
progesteroneTest: (encryptSensitive
? null
: merged.progesteroneTest) as never,
contraceptive: (encryptSensitive ? null : merged.contraceptive) as never,
sensitiveEncrypted: encryptSensitive
? encrypt(JSON.stringify(merged))
: null,
...(body.note !== undefined && {
notesEncrypted: body.note ? encrypt(body.note) : null,
}),
@@ -104,25 +139,11 @@ export const PATCH = apiHandler(
// Replace symptom links only when `symptoms` was supplied.
if (body.symptoms !== undefined) {
const keys = Array.from(new Set(body.symptoms.map((s) => s.key)));
const symptoms =
keys.length > 0
? await prisma.cycleSymptom.findMany({
where: {
key: { in: keys },
isActive: true,
OR: [{ userId: null }, { userId: user.id }],
},
select: { id: true },
})
: [];
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: id } });
if (symptoms.length > 0) {
await prisma.cycleSymptomLink.createMany({
data: symptoms.map((s) => ({ dayLogId: id, symptomId: s.id })),
skipDuplicates: true,
});
}
await replaceSymptomLinks(
user.id,
id,
body.symptoms.map((s) => s.key),
);
}
const row = await prisma.cycleDayLog.findUniqueOrThrow({
@@ -190,3 +211,51 @@ export const DELETE = apiHandler(
return new Response(null, { status: 204 });
},
);
/** The stored plaintext sensitive fields (envelope when encrypted, else columns). */
function readStoredSensitive(row: {
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
progesteroneTest: string | null;
contraceptive: string | null;
sensitiveEncrypted: string | null;
}): {
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
progesteroneTest: string | null;
contraceptive: string | null;
} {
if (row.sensitiveEncrypted) {
try {
const dec = JSON.parse(decrypt(row.sensitiveEncrypted)) as Record<
string,
unknown
>;
return {
sexualActivity: (dec.sexualActivity as boolean) ?? false,
protectedSex: (dec.protectedSex as boolean | null) ?? null,
pregnancyTest: (dec.pregnancyTest as string | null) ?? null,
progesteroneTest: (dec.progesteroneTest as string | null) ?? null,
contraceptive: (dec.contraceptive as string | null) ?? null,
};
} catch {
// Fail-soft: an undecryptable envelope reads as cleared.
return {
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
};
}
}
return {
sexualActivity: row.sexualActivity,
protectedSex: row.protectedSex,
pregnancyTest: row.pregnancyTest,
progesteroneTest: row.progesteroneTest,
contraceptive: row.contraceptive,
};
}
+21 -1
View File
@@ -123,8 +123,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
...(entry.externalId ? { externalId: entry.externalId } : {}),
});
} catch (err: unknown) {
// Map to a stable closed set of reason codes — never echo the raw
// driver message (it can carry column / constraint / value fragments
// and bypasses the central redaction boundary). The full message is
// logged through the WideEvent path where redaction applies.
const code =
typeof err === "object" && err !== null && "code" in err
? (err as { code?: unknown }).code
: undefined;
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
code === "P2002"
? "constraint"
: code === "P2003"
? "constraint"
: "upsert_failed";
annotate({
action: { name: "cycle.bulk.entry-failed" },
meta: {
index: i,
reason,
error: err instanceof Error ? err.message : String(err),
},
});
skipped += 1;
results.push({
index: i,
+24 -1
View File
@@ -19,6 +19,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
@@ -57,7 +58,29 @@ async function postDayLog(request: NextRequest): Promise<Response> {
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const cycleId = await findOwningCycleId(user.id, entry.date);
const result = await upsertCycleDayLog(user.id, entry, tz, cycleId);
let result;
try {
result = await upsertCycleDayLog(user.id, entry, tz, cycleId);
} catch (err: unknown) {
// A residual unique-constraint collision (the helper adopts the
// canonical row on the common case) surfaces as a clean 409, never a
// 500 (the MoodEntry conflict precedent).
if (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code === "P2002"
) {
annotate({
action: { name: "cycle.day-log.conflict" },
meta: { date: entry.date },
});
return apiError("Day-log already exists for this date", 409, {
errorCode: "cycle.day-log.conflict",
});
}
throw err;
}
const row = await prisma.cycleDayLog.findUniqueOrThrow({
where: { id: result.id },