mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
3e398295a3
commit
ac13dd28ed
22 files changed
+770
-174
No files matched your search
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user